1use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::path::Path;
18
19pub const DEPLOYMENT_RECORD_FILE: &str = ".sdkt-deployments.json";
21
22pub const TESTNET_PASSPHRASE: &str = "Test SDF Network ; September 2015";
24pub const MAINNET_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015";
25pub const FUTURENET_PASSPHRASE: &str = "Test SDF Future Network ; October 2022";
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct DeploymentRecord {
30 pub contract_id: String,
32 pub wasm_hash: String,
34 pub network: String,
36 pub timestamp: u64,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub salt: Option<String>,
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
45pub struct DeploymentRecordFile {
46 pub profiles: HashMap<String, HashMap<String, DeploymentRecord>>,
47}
48
49impl DeploymentRecordFile {
50 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self, String> {
54 let path = path.as_ref();
55 if !path.exists() {
56 return Ok(DeploymentRecordFile::default());
57 }
58 let raw = std::fs::read_to_string(path)
59 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
60 if raw.trim().is_empty() {
61 return Ok(DeploymentRecordFile::default());
62 }
63 serde_json::from_str(&raw).map_err(|e| format!("Failed to parse {}: {e}", path.display()))
64 }
65
66 pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), String> {
75 use std::io::Write as _;
76
77 let path = path.as_ref();
78 let json = serde_json::to_string_pretty(self)
79 .map_err(|e| format!("Failed to serialize deployment record: {e}"))?;
80
81 let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
84 let tmp_path = parent.join(format!(".sdkt-deployments.tmp.{}", std::process::id()));
85
86 let mut tmp_file = std::fs::File::create(&tmp_path).map_err(|e| {
87 format!(
88 "Failed to create temp record file {}: {e}",
89 tmp_path.display()
90 )
91 })?;
92 tmp_file.write_all(json.as_bytes()).map_err(|e| {
93 format!(
94 "Failed to write temp record file {}: {e}",
95 tmp_path.display()
96 )
97 })?;
98 tmp_file.sync_all().map_err(|e| {
99 format!(
100 "Failed to sync temp record file {}: {e}",
101 tmp_path.display()
102 )
103 })?;
104 drop(tmp_file);
105
106 std::fs::rename(&tmp_path, path).map_err(|e| {
107 let _ = std::fs::remove_file(&tmp_path);
109 format!(
110 "Failed to rename temp record file to {}: {e}",
111 path.display()
112 )
113 })
114 }
115
116 pub fn records_for(&self, network: &str) -> Option<&HashMap<String, DeploymentRecord>> {
118 self.profiles.get(network)
119 }
120
121 pub fn record_for(&self, network: &str, alias: &str) -> Option<&DeploymentRecord> {
123 self.records_for(network)
124 .and_then(|records| records.get(alias))
125 }
126
127 pub fn set_record(&mut self, network: &str, alias: &str, record: DeploymentRecord) {
129 self.profiles
130 .entry(network.to_string())
131 .or_default()
132 .insert(alias.to_string(), record);
133 }
134
135 pub fn remove_record(&mut self, network: &str, alias: &str) {
138 if let Some(records) = self.profiles.get_mut(network) {
139 records.remove(alias);
140 }
141 }
142}
143
144pub fn network_key(network_profile: Option<&str>, passphrase: &str) -> String {
154 if let Some(profile) = network_profile {
155 if !profile.trim().is_empty() {
156 return profile.to_string();
157 }
158 }
159
160 match passphrase {
161 TESTNET_PASSPHRASE => "testnet".to_string(),
162 MAINNET_PASSPHRASE => "mainnet".to_string(),
163 FUTURENET_PASSPHRASE => "futurenet".to_string(),
164 other => {
165 use sha2::Digest;
166 let digest = sha2::Sha256::digest(other.as_bytes());
167 let prefix: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
168 format!("custom-{prefix}")
169 }
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 fn record(alias: &str, contract_id: &str, network: &str) -> DeploymentRecord {
178 DeploymentRecord {
179 contract_id: contract_id.to_string(),
180 wasm_hash: "ab12cd34".to_string(),
181 network: network.to_string(),
182 timestamp: 1_700_000_000,
183 salt: Some(alias.to_string()),
184 }
185 }
186
187 #[test]
188 fn record_file_write_read_round_trip() {
189 let dir = tempfile::tempdir().unwrap();
190 let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
191
192 let mut file = DeploymentRecordFile::default();
193 file.set_record("testnet", "token", record("token", "CTOKEN", "testnet"));
194 file.set_record("testnet", "vault", record("vault", "CVAULT", "testnet"));
195 file.write(&path).unwrap();
196
197 let loaded = DeploymentRecordFile::read(&path).unwrap();
198 assert_eq!(loaded, file);
199 assert_eq!(
200 loaded.record_for("testnet", "token").unwrap().contract_id,
201 "CTOKEN"
202 );
203 assert_eq!(
204 loaded.record_for("testnet", "vault").unwrap().contract_id,
205 "CVAULT"
206 );
207 }
208
209 #[test]
210 fn missing_record_file_reads_empty() {
211 let dir = tempfile::tempdir().unwrap();
212 let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
213 let file = DeploymentRecordFile::read(&path).unwrap();
214 assert!(file.profiles.is_empty());
215 }
216
217 #[test]
218 fn corrupt_record_file_is_an_error_not_a_silent_reset() {
219 let dir = tempfile::tempdir().unwrap();
220 let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
221 std::fs::write(&path, "not json {").unwrap();
222 assert!(DeploymentRecordFile::read(&path).is_err());
223 }
224
225 #[test]
226 fn records_are_scoped_per_network_profile() {
227 let dir = tempfile::tempdir().unwrap();
228 let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
229
230 let mut file = DeploymentRecordFile::default();
231 file.set_record("testnet", "token", record("token", "C_TESTNET", "testnet"));
232 file.set_record("mainnet", "token", record("token", "C_MAINNET", "mainnet"));
233 file.write(&path).unwrap();
234
235 let loaded = DeploymentRecordFile::read(&path).unwrap();
236 assert_eq!(
238 loaded.record_for("testnet", "token").unwrap().contract_id,
239 "C_TESTNET"
240 );
241 assert_eq!(
242 loaded.record_for("mainnet", "token").unwrap().contract_id,
243 "C_MAINNET"
244 );
245 assert_eq!(loaded.records_for("testnet").unwrap().len(), 1);
246 assert_eq!(loaded.records_for("mainnet").unwrap().len(), 1);
247 }
248
249 #[test]
250 fn writing_testnet_then_mainnet_does_not_overwrite() {
251 let dir = tempfile::tempdir().unwrap();
253 let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
254
255 let mut testnet = DeploymentRecordFile::default();
256 testnet.set_record("testnet", "token", record("token", "C_T", "testnet"));
257 testnet.write(&path).unwrap();
258
259 let mut mainnet = DeploymentRecordFile::read(&path).unwrap();
260 mainnet.set_record("mainnet", "token", record("token", "C_M", "mainnet"));
261 mainnet.write(&path).unwrap();
262
263 let loaded = DeploymentRecordFile::read(&path).unwrap();
264 assert_eq!(
265 loaded.record_for("testnet", "token").unwrap().contract_id,
266 "C_T"
267 );
268 assert_eq!(
269 loaded.record_for("mainnet", "token").unwrap().contract_id,
270 "C_M"
271 );
272 }
273
274 #[test]
275 fn record_lookup_and_replace_semantics() {
276 let mut file = DeploymentRecordFile::default();
277 assert!(file.record_for("testnet", "token").is_none());
278 file.set_record("testnet", "token", record("token", "C1", "testnet"));
279 assert_eq!(
280 file.record_for("testnet", "token").unwrap().contract_id,
281 "C1"
282 );
283 file.set_record("testnet", "token", record("token", "C2", "testnet"));
284 assert_eq!(
285 file.record_for("testnet", "token").unwrap().contract_id,
286 "C2"
287 );
288 file.remove_record("testnet", "token");
289 assert!(file.record_for("testnet", "token").is_none());
290 }
291
292 #[test]
293 fn network_key_prefers_explicit_profile() {
294 assert_eq!(
295 network_key(Some("my-testnet"), TESTNET_PASSPHRASE),
296 "my-testnet"
297 );
298 assert_eq!(network_key(Some(" "), TESTNET_PASSPHRASE), "testnet");
300 }
301
302 #[test]
303 fn network_key_maps_known_passphrases() {
304 assert_eq!(network_key(None, TESTNET_PASSPHRASE), "testnet");
305 assert_eq!(network_key(None, MAINNET_PASSPHRASE), "mainnet");
306 assert_eq!(network_key(None, FUTURENET_PASSPHRASE), "futurenet");
307 }
308
309 #[test]
310 fn network_key_for_custom_passphrases_is_deterministic_and_distinct() {
311 let a = network_key(None, "Standalone Network ; 2024");
312 let b = network_key(None, "Standalone Network ; 2024");
313 let c = network_key(None, "Custom Net / 2025");
314 assert_eq!(a, b);
315 assert_ne!(a, c);
316 assert_ne!(network_key(None, TESTNET_PASSPHRASE), a);
318 }
319}