Skip to main content

sdkt_storage/
network.rs

1//! Network profile storage for the Soroban DevKit.
2//!
3//! Manages named network configurations that can be referenced by profile name
4//! in CLI commands instead of specifying full RPC URLs and network passphrases.
5//!
6//! # Example
7//!
8//! ```
9//! use sdkt_storage::NetworkProfile;
10//!
11//! let profile = NetworkProfile::new(
12//! "testnet",
13//! "https://soroban-testnet.stellar.org",
14//! "Test SDF Network ; September 2015",
15//! )
16//! .with_friendbot("https://friendbot.stellar.org");
17//!
18//! assert_eq!(profile.name, "testnet");
19//! assert_eq!(profile.rpc_url, "https://soroban-testnet.stellar.org");
20//! assert_eq!(profile.friendbot_url.as_deref(), Some("https://friendbot.stellar.org"));
21//! ```
22
23use directories::ProjectDirs;
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26use std::fs;
27use std::path::{Path, PathBuf};
28
29use crate::error::StorageError;
30
31/// A named network profile with RPC endpoint and network passphrase.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct NetworkProfile {
34    /// Human-readable name for this profile
35    pub name: String,
36    /// RPC endpoint URL
37    pub rpc_url: String,
38    /// Network passphrase (e.g., "Test SDF Network ; September 2015")
39    pub network_passphrase: String,
40    /// Optional: default friendbot URL for test networks
41    pub friendbot_url: Option<String>,
42    /// Optional: description or notes
43    pub description: Option<String>,
44}
45
46impl NetworkProfile {
47    /// Create a new network profile.
48    pub fn new(
49        name: impl Into<String>,
50        rpc_url: impl Into<String>,
51        network_passphrase: impl Into<String>,
52    ) -> Self {
53        Self {
54            name: name.into(),
55            rpc_url: rpc_url.into(),
56            network_passphrase: network_passphrase.into(),
57            friendbot_url: None,
58            description: None,
59        }
60    }
61
62    /// Set the friendbot URL.
63    pub fn with_friendbot(mut self, url: impl Into<String>) -> Self {
64        self.friendbot_url = Some(url.into());
65        self
66    }
67
68    /// Set the description.
69    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
70        self.description = Some(desc.into());
71        self
72    }
73
74    /// Validate the profile's required fields.
75    ///
76    /// Returns [`StorageError::ConfigError`] if the name or RPC URL is empty,
77    /// or if the name contains a path separator (which would break the
78    /// on-disk filename). The network passphrase is allowed to be empty only
79    /// for local dev networks, but a missing RPC URL is always invalid.
80    pub fn validate(&self) -> Result<(), StorageError> {
81        if self.name.is_empty() {
82            return Err(StorageError::ConfigError(
83                "Network profile name is empty".into(),
84            ));
85        }
86        if self.name.contains('/') || self.name.contains('\\') {
87            return Err(StorageError::ConfigError(format!(
88                "Network profile name '{}' must not contain a path separator",
89                self.name
90            )));
91        }
92        if self.rpc_url.is_empty() {
93            return Err(StorageError::ConfigError(format!(
94                "Network profile '{}' has an empty RPC URL",
95                self.name
96            )));
97        }
98        Ok(())
99    }
100}
101
102/// Storage for network profiles.
103///
104/// Profiles are stored as individual JSON files in a directory, similar to identities.
105/// The directory location can be overridden via `SDKT_NETWORK_DIR` environment variable
106/// for testing or custom configurations; otherwise it uses the OS config directory
107/// (`~/.config/sdkt/networks` on Linux, matching the `IdentityStore` convention).
108pub struct NetworkStore {
109    base_dir: PathBuf,
110}
111
112impl NetworkStore {
113    /// Create a new network store.
114    ///
115    /// If the `SDKT_NETWORK_DIR` environment variable is set to a non-empty value,
116    /// it is used as the base directory. This is the canonical cross-platform way for
117    /// tests and CI to isolate the network store (mirrors `SDKT_IDENTITY_DIR`).
118    ///
119    /// Otherwise, falls back to the OS config directory via `directories::ProjectDirs`:
120    /// - Linux: `$XDG_CONFIG_HOME/sdkt/networks` or `~/.config/sdkt/networks`
121    /// - macOS: `~/Library/Application Support/sdkt/networks`
122    /// - Windows: `%APPDATA%\sdkt\networks`
123    pub fn new() -> Result<Self, StorageError> {
124        if let Ok(dir) = std::env::var("SDKT_NETWORK_DIR") {
125            if !dir.is_empty() {
126                return Self::with_dir(dir);
127            }
128        }
129
130        let proj_dirs = ProjectDirs::from("com", "SorobanDevKit", "sdkt").ok_or_else(|| {
131            StorageError::ConfigError("Cannot determine config directory: no home dir".into())
132        })?;
133
134        Self::with_dir(proj_dirs.config_dir().join("networks"))
135    }
136
137    /// Create a network store rooted at an explicit directory.
138    ///
139    /// The directory is created if it does not already exist. This is the
140    /// injection point used by tests and by callers that want to manage
141    /// profiles in a non-default location without touching environment state.
142    pub fn with_dir<P: AsRef<Path>>(dir: P) -> Result<Self, StorageError> {
143        let base_dir = dir.as_ref().to_path_buf();
144
145        fs::create_dir_all(&base_dir).map_err(StorageError::Io)?;
146
147        Ok(Self { base_dir })
148    }
149
150    /// Get the path for a specific profile.
151    fn profile_path(&self, name: &str) -> PathBuf {
152        self.base_dir.join(format!("{}.json", name))
153    }
154
155    /// List all network profiles.
156    pub fn list(&self) -> Result<Vec<NetworkProfile>, StorageError> {
157        let mut profiles = Vec::new();
158
159        let entries = fs::read_dir(&self.base_dir).map_err(StorageError::Io)?;
160
161        for entry in entries {
162            let entry = entry.map_err(StorageError::Io)?;
163
164            let path = entry.path();
165            if path.extension().and_then(|s| s.to_str()) == Some("json") {
166                match self.load_profile_from_path(&path) {
167                    Ok(profile) => profiles.push(profile),
168                    Err(_) => {
169                        // Skip invalid profile files
170                        continue;
171                    }
172                }
173            }
174        }
175
176        // Sort by name for consistent output
177        profiles.sort_by(|a, b| a.name.cmp(&b.name));
178        Ok(profiles)
179    }
180
181    /// Get a specific network profile by name.
182    pub fn get(&self, name: &str) -> Result<NetworkProfile, StorageError> {
183        let path = self.profile_path(name);
184        if !path.exists() {
185            return Err(StorageError::NotFound(format!(
186                "Network profile '{}' not found",
187                name
188            )));
189        }
190        self.load_profile_from_path(&path)
191    }
192
193    /// Load a profile from a specific path.
194    fn load_profile_from_path(&self, path: &Path) -> Result<NetworkProfile, StorageError> {
195        let content = fs::read_to_string(path).map_err(StorageError::Io)?;
196
197        let profile: NetworkProfile = serde_json::from_str(&content).map_err(|e| {
198            StorageError::Parse(format!("Failed to parse {}: {}", path.display(), e))
199        })?;
200
201        Ok(profile)
202    }
203
204    /// Add or update a network profile.
205    pub fn add(&self, profile: NetworkProfile) -> Result<(), StorageError> {
206        profile.validate()?;
207
208        let path = self.profile_path(&profile.name);
209        let content = serde_json::to_string_pretty(&profile)
210            .map_err(|e| StorageError::Parse(format!("Failed to serialize profile: {}", e)))?;
211
212        fs::write(&path, content).map_err(StorageError::Io)?;
213
214        Ok(())
215    }
216
217    /// Remove a network profile.
218    pub fn remove(&self, name: &str) -> Result<(), StorageError> {
219        let path = self.profile_path(name);
220        if !path.exists() {
221            return Err(StorageError::NotFound(format!(
222                "Network profile '{}' not found",
223                name
224            )));
225        }
226
227        fs::remove_file(&path).map_err(StorageError::Io)?;
228
229        Ok(())
230    }
231
232    /// Check if a profile exists.
233    pub fn exists(&self, name: &str) -> bool {
234        self.profile_path(name).exists()
235    }
236
237    /// Get all profiles as a map keyed by profile name.
238    pub fn as_map(&self) -> Result<HashMap<String, NetworkProfile>, StorageError> {
239        let profiles = self.list()?;
240        let mut map = HashMap::new();
241        for profile in profiles {
242            map.insert(profile.name.clone(), profile);
243        }
244        Ok(map)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use tempfile::tempdir;
252
253    #[test]
254    fn test_network_store_with_dir_add_get_remove() {
255        let temp_dir = tempdir().unwrap();
256        let store = NetworkStore::with_dir(temp_dir.path()).unwrap();
257
258        // Add a profile
259        let profile = NetworkProfile::new(
260            "testnet",
261            "https://soroban-testnet.stellar.org",
262            "Test SDF Network ; September 2015",
263        )
264        .with_friendbot("https://friendbot.stellar.org")
265        .with_description("Stellar testnet");
266
267        store.add(profile.clone()).unwrap();
268
269        // List profiles
270        let profiles = store.list().unwrap();
271        assert_eq!(profiles.len(), 1);
272        assert_eq!(profiles[0].name, "testnet");
273
274        // Get profile
275        let retrieved = store.get("testnet").unwrap();
276        assert_eq!(retrieved.rpc_url, profile.rpc_url);
277        assert_eq!(retrieved.network_passphrase, profile.network_passphrase);
278        assert_eq!(
279            retrieved.friendbot_url.as_deref(),
280            Some("https://friendbot.stellar.org")
281        );
282        assert_eq!(retrieved.description.as_deref(), Some("Stellar testnet"));
283
284        // Exists + remove
285        assert!(store.exists("testnet"));
286        store.remove("testnet").unwrap();
287        assert!(!store.exists("testnet"));
288    }
289
290    #[test]
291    fn test_get_missing_profile_errors() {
292        let temp_dir = tempdir().unwrap();
293        let store = NetworkStore::with_dir(temp_dir.path()).unwrap();
294
295        let result = store.get("nonexistent");
296        assert!(matches!(result, Err(StorageError::NotFound(_))));
297        assert!(!store.exists("nonexistent"));
298    }
299
300    #[test]
301    fn test_add_overwrites_existing_profile() {
302        let temp_dir = tempdir().unwrap();
303        let store = NetworkStore::with_dir(temp_dir.path()).unwrap();
304
305        let first = NetworkProfile::new("net", "https://old.example", "Old Passphrase");
306        store.add(first).unwrap();
307
308        let updated = NetworkProfile::new("net", "https://new.example", "New Passphrase")
309            .with_description("updated");
310        store.add(updated).unwrap();
311
312        // Still exactly one profile after overwrite.
313        let profiles = store.list().unwrap();
314        assert_eq!(profiles.len(), 1);
315
316        let retrieved = store.get("net").unwrap();
317        assert_eq!(retrieved.rpc_url, "https://new.example");
318        assert_eq!(retrieved.network_passphrase, "New Passphrase");
319        assert_eq!(retrieved.description.as_deref(), Some("updated"));
320    }
321
322    #[test]
323    fn test_as_map_keys_by_name() {
324        let temp_dir = tempdir().unwrap();
325        let store = NetworkStore::with_dir(temp_dir.path()).unwrap();
326
327        store
328            .add(NetworkProfile::new("alpha", "https://a.example", "A"))
329            .unwrap();
330        store
331            .add(NetworkProfile::new("beta", "https://b.example", "B"))
332            .unwrap();
333
334        let map = store.as_map().unwrap();
335        assert_eq!(map.len(), 2);
336        assert_eq!(map.get("alpha").unwrap().rpc_url, "https://a.example");
337        assert_eq!(map.get("beta").unwrap().rpc_url, "https://b.example");
338    }
339
340    #[test]
341    fn test_validate_rejects_empty_name_and_rpc() {
342        let empty_name = NetworkProfile::new("", "https://x.example", "P");
343        assert!(empty_name.validate().is_err());
344
345        let sep_name = NetworkProfile::new("a/b", "https://x.example", "P");
346        assert!(sep_name.validate().is_err());
347
348        let empty_rpc = NetworkProfile::new("ok", "", "P");
349        assert!(empty_rpc.validate().is_err());
350
351        let valid = NetworkProfile::new("ok", "https://x.example", "P");
352        assert!(valid.validate().is_ok());
353    }
354
355    #[test]
356    fn test_invalid_profile_json_is_skipped_on_list() {
357        let temp_dir = tempdir().unwrap();
358        let store = NetworkStore::with_dir(temp_dir.path()).unwrap();
359
360        // Write a valid profile.
361        store
362            .add(NetworkProfile::new("good", "https://good.example", "G"))
363            .unwrap();
364
365        // Write a corrupt profile file directly into the store directory.
366        let bad_path = store.profile_path("corrupt");
367        fs::write(&bad_path, "{ not valid json").unwrap();
368
369        let profiles = store.list().unwrap();
370        assert_eq!(profiles.len(), 1);
371        assert_eq!(profiles[0].name, "good");
372    }
373
374    #[test]
375    fn test_remove_missing_profile_errors() {
376        let temp_dir = tempdir().unwrap();
377        let store = NetworkStore::with_dir(temp_dir.path()).unwrap();
378
379        let result = store.remove("ghost");
380        assert!(matches!(result, Err(StorageError::NotFound(_))));
381    }
382}