1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct NetworkProfile {
34 pub name: String,
36 pub rpc_url: String,
38 pub network_passphrase: String,
40 pub friendbot_url: Option<String>,
42 pub description: Option<String>,
44}
45
46impl NetworkProfile {
47 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 pub fn with_friendbot(mut self, url: impl Into<String>) -> Self {
64 self.friendbot_url = Some(url.into());
65 self
66 }
67
68 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
70 self.description = Some(desc.into());
71 self
72 }
73
74 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
102pub struct NetworkStore {
109 base_dir: PathBuf,
110}
111
112impl NetworkStore {
113 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 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 fn profile_path(&self, name: &str) -> PathBuf {
152 self.base_dir.join(format!("{}.json", name))
153 }
154
155 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 continue;
171 }
172 }
173 }
174 }
175
176 profiles.sort_by(|a, b| a.name.cmp(&b.name));
178 Ok(profiles)
179 }
180
181 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 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 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 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 pub fn exists(&self, name: &str) -> bool {
234 self.profile_path(name).exists()
235 }
236
237 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 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 let profiles = store.list().unwrap();
271 assert_eq!(profiles.len(), 1);
272 assert_eq!(profiles[0].name, "testnet");
273
274 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 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 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 store
362 .add(NetworkProfile::new("good", "https://good.example", "G"))
363 .unwrap();
364
365 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}