use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
pub const DEPLOYMENT_RECORD_FILE: &str = ".sdkt-deployments.json";
pub const TESTNET_PASSPHRASE: &str = "Test SDF Network ; September 2015";
pub const MAINNET_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015";
pub const FUTURENET_PASSPHRASE: &str = "Test SDF Future Network ; October 2022";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeploymentRecord {
pub contract_id: String,
pub wasm_hash: String,
pub network: String,
pub timestamp: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub salt: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct DeploymentRecordFile {
pub profiles: HashMap<String, HashMap<String, DeploymentRecord>>,
}
impl DeploymentRecordFile {
pub fn read<P: AsRef<Path>>(path: P) -> Result<Self, String> {
let path = path.as_ref();
if !path.exists() {
return Ok(DeploymentRecordFile::default());
}
let raw = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
if raw.trim().is_empty() {
return Ok(DeploymentRecordFile::default());
}
serde_json::from_str(&raw).map_err(|e| format!("Failed to parse {}: {e}", path.display()))
}
pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), String> {
use std::io::Write as _;
let path = path.as_ref();
let json = serde_json::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize deployment record: {e}"))?;
let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
let tmp_path = parent.join(format!(".sdkt-deployments.tmp.{}", std::process::id()));
let mut tmp_file = std::fs::File::create(&tmp_path).map_err(|e| {
format!(
"Failed to create temp record file {}: {e}",
tmp_path.display()
)
})?;
tmp_file.write_all(json.as_bytes()).map_err(|e| {
format!(
"Failed to write temp record file {}: {e}",
tmp_path.display()
)
})?;
tmp_file.sync_all().map_err(|e| {
format!(
"Failed to sync temp record file {}: {e}",
tmp_path.display()
)
})?;
drop(tmp_file);
std::fs::rename(&tmp_path, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
format!(
"Failed to rename temp record file to {}: {e}",
path.display()
)
})
}
pub fn records_for(&self, network: &str) -> Option<&HashMap<String, DeploymentRecord>> {
self.profiles.get(network)
}
pub fn record_for(&self, network: &str, alias: &str) -> Option<&DeploymentRecord> {
self.records_for(network)
.and_then(|records| records.get(alias))
}
pub fn set_record(&mut self, network: &str, alias: &str, record: DeploymentRecord) {
self.profiles
.entry(network.to_string())
.or_default()
.insert(alias.to_string(), record);
}
pub fn remove_record(&mut self, network: &str, alias: &str) {
if let Some(records) = self.profiles.get_mut(network) {
records.remove(alias);
}
}
}
pub fn network_key(network_profile: Option<&str>, passphrase: &str) -> String {
if let Some(profile) = network_profile {
if !profile.trim().is_empty() {
return profile.to_string();
}
}
match passphrase {
TESTNET_PASSPHRASE => "testnet".to_string(),
MAINNET_PASSPHRASE => "mainnet".to_string(),
FUTURENET_PASSPHRASE => "futurenet".to_string(),
other => {
use sha2::Digest;
let digest = sha2::Sha256::digest(other.as_bytes());
let prefix: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
format!("custom-{prefix}")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn record(alias: &str, contract_id: &str, network: &str) -> DeploymentRecord {
DeploymentRecord {
contract_id: contract_id.to_string(),
wasm_hash: "ab12cd34".to_string(),
network: network.to_string(),
timestamp: 1_700_000_000,
salt: Some(alias.to_string()),
}
}
#[test]
fn record_file_write_read_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
let mut file = DeploymentRecordFile::default();
file.set_record("testnet", "token", record("token", "CTOKEN", "testnet"));
file.set_record("testnet", "vault", record("vault", "CVAULT", "testnet"));
file.write(&path).unwrap();
let loaded = DeploymentRecordFile::read(&path).unwrap();
assert_eq!(loaded, file);
assert_eq!(
loaded.record_for("testnet", "token").unwrap().contract_id,
"CTOKEN"
);
assert_eq!(
loaded.record_for("testnet", "vault").unwrap().contract_id,
"CVAULT"
);
}
#[test]
fn missing_record_file_reads_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
let file = DeploymentRecordFile::read(&path).unwrap();
assert!(file.profiles.is_empty());
}
#[test]
fn corrupt_record_file_is_an_error_not_a_silent_reset() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
std::fs::write(&path, "not json {").unwrap();
assert!(DeploymentRecordFile::read(&path).is_err());
}
#[test]
fn records_are_scoped_per_network_profile() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
let mut file = DeploymentRecordFile::default();
file.set_record("testnet", "token", record("token", "C_TESTNET", "testnet"));
file.set_record("mainnet", "token", record("token", "C_MAINNET", "mainnet"));
file.write(&path).unwrap();
let loaded = DeploymentRecordFile::read(&path).unwrap();
assert_eq!(
loaded.record_for("testnet", "token").unwrap().contract_id,
"C_TESTNET"
);
assert_eq!(
loaded.record_for("mainnet", "token").unwrap().contract_id,
"C_MAINNET"
);
assert_eq!(loaded.records_for("testnet").unwrap().len(), 1);
assert_eq!(loaded.records_for("mainnet").unwrap().len(), 1);
}
#[test]
fn writing_testnet_then_mainnet_does_not_overwrite() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(DEPLOYMENT_RECORD_FILE);
let mut testnet = DeploymentRecordFile::default();
testnet.set_record("testnet", "token", record("token", "C_T", "testnet"));
testnet.write(&path).unwrap();
let mut mainnet = DeploymentRecordFile::read(&path).unwrap();
mainnet.set_record("mainnet", "token", record("token", "C_M", "mainnet"));
mainnet.write(&path).unwrap();
let loaded = DeploymentRecordFile::read(&path).unwrap();
assert_eq!(
loaded.record_for("testnet", "token").unwrap().contract_id,
"C_T"
);
assert_eq!(
loaded.record_for("mainnet", "token").unwrap().contract_id,
"C_M"
);
}
#[test]
fn record_lookup_and_replace_semantics() {
let mut file = DeploymentRecordFile::default();
assert!(file.record_for("testnet", "token").is_none());
file.set_record("testnet", "token", record("token", "C1", "testnet"));
assert_eq!(
file.record_for("testnet", "token").unwrap().contract_id,
"C1"
);
file.set_record("testnet", "token", record("token", "C2", "testnet"));
assert_eq!(
file.record_for("testnet", "token").unwrap().contract_id,
"C2"
);
file.remove_record("testnet", "token");
assert!(file.record_for("testnet", "token").is_none());
}
#[test]
fn network_key_prefers_explicit_profile() {
assert_eq!(
network_key(Some("my-testnet"), TESTNET_PASSPHRASE),
"my-testnet"
);
assert_eq!(network_key(Some(" "), TESTNET_PASSPHRASE), "testnet");
}
#[test]
fn network_key_maps_known_passphrases() {
assert_eq!(network_key(None, TESTNET_PASSPHRASE), "testnet");
assert_eq!(network_key(None, MAINNET_PASSPHRASE), "mainnet");
assert_eq!(network_key(None, FUTURENET_PASSPHRASE), "futurenet");
}
#[test]
fn network_key_for_custom_passphrases_is_deterministic_and_distinct() {
let a = network_key(None, "Standalone Network ; 2024");
let b = network_key(None, "Standalone Network ; 2024");
let c = network_key(None, "Custom Net / 2025");
assert_eq!(a, b);
assert_ne!(a, c);
assert_ne!(network_key(None, TESTNET_PASSPHRASE), a);
}
}