Skip to content

Commit bc0b0bd

Browse files
committed
fix: address PR review issues for rpc endpoint profiles
1 parent d2cf176 commit bc0b0bd

File tree

2 files changed

+145
-30
lines changed

2 files changed

+145
-30
lines changed

ows/crates/ows-cli/src/commands/rpc.rs

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,27 @@ fn nearest_profile<'a>(input: &str, profiles: &'a [&str]) -> Option<&'a str> {
1515
/// Compute Levenshtein distance between two strings (no external deps).
1616
#[allow(clippy::needless_range_loop)]
1717
fn edit_distance(a: &str, b: &str) -> usize {
18-
let mut matrix: Vec<Vec<usize>> = vec![vec![0; b.len() + 1]; a.len() + 1];
19-
for i in 0..=a.len() {
18+
let a_chars: Vec<char> = a.chars().collect();
19+
let b_chars: Vec<char> = b.chars().collect();
20+
let a_len = a_chars.len();
21+
let b_len = b_chars.len();
22+
23+
let mut matrix: Vec<Vec<usize>> = vec![vec![0; b_len + 1]; a_len + 1];
24+
for i in 0..=a_len {
2025
matrix[i][0] = i;
2126
}
22-
for j in 0..=b.len() {
27+
for j in 0..=b_len {
2328
matrix[0][j] = j;
2429
}
25-
for (i, ca) in a.char_indices() {
26-
for (j, cb) in b.char_indices() {
30+
for (i, ca) in a_chars.iter().enumerate() {
31+
for (j, cb) in b_chars.iter().enumerate() {
2732
let cost = if ca == cb { 0 } else { 1 };
2833
matrix[i + 1][j + 1] = (matrix[i][j + 1] + 1)
2934
.min(matrix[i + 1][j] + 1)
3035
.min(matrix[i][j] + cost);
3136
}
3237
}
33-
matrix[a.len()][b.len()]
38+
matrix[a_len][b_len]
3439
}
3540

3641
/// Show the active RPC profile and its endpoints, or a specific profile.
@@ -136,17 +141,21 @@ pub fn add(name: &str, chains: &[String], urls: &[String]) -> Result<(), CliErro
136141
));
137142
}
138143

139-
// Validate all chains first before making any changes
140-
for chain in chains {
141-
let _ = ows_core::parse_chain(chain)
142-
.map_err(|e| CliError::InvalidArgs(format!("invalid chain '{}': {}", chain, e)))?;
143-
}
144+
// Validate and normalize all chains first before making any changes
145+
let normalized_chains: Vec<String> = chains
146+
.iter()
147+
.map(|chain| {
148+
ows_core::parse_chain(chain)
149+
.map(|c| c.chain_id.to_string())
150+
.map_err(|e| CliError::InvalidArgs(format!("invalid chain '{}': {}", chain, e)))
151+
})
152+
.collect::<Result<Vec<_>, _>>()?;
144153

145154
let mut config = Config::load_or_default();
146155

147156
let is_new = config.profile(name).is_none();
148157

149-
for (chain, url) in chains.iter().zip(urls.iter()) {
158+
for (chain, url) in normalized_chains.iter().zip(urls.iter()) {
150159
config.upsert_profile_endpoint(name, chain, url.clone());
151160
}
152161

@@ -155,7 +164,7 @@ pub fn add(name: &str, chains: &[String], urls: &[String]) -> Result<(), CliErro
155164
if is_new {
156165
println!("Created new profile: {}", name);
157166
}
158-
for (chain, url) in chains.iter().zip(urls.iter()) {
167+
for (chain, url) in normalized_chains.iter().zip(urls.iter()) {
159168
println!("Added {} -> {} in profile '{}'", chain, url, name);
160169
}
161170

@@ -274,6 +283,20 @@ mod tests {
274283
assert_eq!(edit_distance("team-dev", "team-prod"), 4);
275284
}
276285

286+
#[test]
287+
fn test_edit_distance_multibyte_utf8() {
288+
// Regression: edit_distance must count characters, not bytes.
289+
// 'é' is 2 bytes in UTF-8; a byte-oriented implementation would produce
290+
// wrong matrix dimensions and incorrect indices for multi-byte chars.
291+
assert_eq!(edit_distance("café", "cafe"), 1); // 'é'(2 bytes) vs 'e'(1 byte): cost 1
292+
assert_eq!(edit_distance("cafe", "café"), 1);
293+
assert_eq!(edit_distance("résumé", "resume"), 2); // 'é','é'(2 bytes each) -> 'e','e': cost 2
294+
assert_eq!(edit_distance("日本", "日本"), 0); // identical 6-byte strings
295+
assert_eq!(edit_distance("日本", "日韩"), 1); // different 3-byte chars: substitution cost 1
296+
assert_eq!(edit_distance("hello世界", "hello世界"), 0); // identical mixed ASCII/multibyte
297+
assert_eq!(edit_distance("hello世界", "hello世界!"), 1); // insertion at end
298+
}
299+
277300
#[test]
278301
fn test_nearest_profile() {
279302
let profiles = vec!["team-dev", "mainnet-evm", "staging"];

ows/crates/ows-core/src/config.rs

Lines changed: 109 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ pub struct Config {
6565
#[serde(default = "default_vault_path")]
6666
pub vault_path: PathBuf,
6767
/// Legacy flat RPC endpoints (chain_id -> url). Used for backward compatibility.
68-
#[serde(default)]
68+
/// Empty by default when loading from disk — built-in defaults come from
69+
/// `Config::default_rpc()` at runtime and are not persisted.
70+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
6971
pub rpc: HashMap<String, String>,
7072
/// New structured RPC config with profile support.
7173
/// When present, takes precedence over legacy `rpc` field for profile lookups.
@@ -144,9 +146,13 @@ impl Default for Config {
144146
}
145147

146148
impl Config {
147-
/// Look up an RPC URL by chain identifier from legacy flat rpc map.
148-
pub fn rpc_url(&self, chain: &str) -> Option<&str> {
149-
self.rpc.get(chain).map(|s| s.as_str())
149+
/// Look up an RPC URL by chain identifier.
150+
/// Checks: user-defined global rpc > built-in defaults.
151+
pub fn rpc_url(&self, chain: &str) -> Option<String> {
152+
self.rpc
153+
.get(chain)
154+
.cloned()
155+
.or_else(|| Config::default_rpc().get(chain).cloned())
150156
}
151157

152158
/// Returns the name of the active profile, if any.
@@ -209,6 +215,7 @@ impl Config {
209215
}
210216

211217
/// Remove a chain endpoint from a profile. Deletes profile if empty.
218+
/// Clears active_profile if the deleted profile was the active one.
212219
pub fn remove_profile_endpoint(&mut self, profile_name: &str, chain: &str) -> bool {
213220
let config = match self.rpc_config.as_mut() {
214221
Some(c) => c,
@@ -221,6 +228,9 @@ impl Config {
221228
let removed = profile.chains.remove(chain).is_some();
222229
if removed && profile.chains.is_empty() {
223230
config.profiles.remove(profile_name);
231+
if config.active_profile.as_deref() == Some(profile_name) {
232+
config.active_profile = None;
233+
}
224234
}
225235
removed
226236
}
@@ -265,7 +275,12 @@ impl Config {
265275

266276
/// Load config from a specific path, merging user overrides on top of defaults.
267277
pub fn load_or_default_from(path: &std::path::Path) -> Self {
268-
let mut config = Config::default();
278+
let mut config = Config {
279+
// Start with empty rpc — built-in defaults come from `default_rpc()` at
280+
// runtime; this avoids baking them into the saved config file.
281+
rpc: HashMap::new(),
282+
..Config::default()
283+
};
269284
if path.exists() {
270285
if let Ok(contents) = std::fs::read_to_string(path) {
271286
if let Ok(user_config) = serde_json::from_str::<Config>(&contents) {
@@ -326,20 +341,26 @@ mod tests {
326341
#[test]
327342
fn test_rpc_lookup_hit() {
328343
let config = Config::default();
329-
assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com"));
344+
assert_eq!(
345+
config.rpc_url("eip155:1"),
346+
Some("https://eth.llamarpc.com".to_string())
347+
);
330348
}
331349

332350
#[test]
333351
fn test_default_rpc_endpoints() {
334352
let config = Config::default();
335-
assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com"));
353+
assert_eq!(
354+
config.rpc_url("eip155:1"),
355+
Some("https://eth.llamarpc.com".to_string())
356+
);
336357
assert_eq!(
337358
config.rpc_url("eip155:137"),
338-
Some("https://polygon-rpc.com")
359+
Some("https://polygon-rpc.com".to_string())
339360
);
340361
assert_eq!(
341362
config.rpc_url("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"),
342-
Some("https://api.mainnet-beta.solana.com")
363+
Some("https://api.mainnet-beta.solana.com".to_string())
343364
);
344365
}
345366

@@ -383,9 +404,13 @@ mod tests {
383404
#[test]
384405
fn test_load_or_default_nonexistent() {
385406
let config = Config::load_or_default_from(std::path::Path::new("/nonexistent/config.json"));
386-
// Should have all default RPCs
387-
assert_eq!(config.rpc.len(), 18);
388-
assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com"));
407+
// rpc is empty (built-in defaults come from default_rpc() at runtime)
408+
assert!(config.rpc.is_empty());
409+
// rpc_url still resolves defaults via fallback
410+
assert_eq!(
411+
config.rpc_url("eip155:1"),
412+
Some("https://eth.llamarpc.com".to_string())
413+
);
389414
}
390415

391416
#[test]
@@ -401,10 +426,13 @@ mod tests {
401426
std::fs::write(&config_path, serde_json::to_string(&user_config).unwrap()).unwrap();
402427

403428
let config = Config::load_or_default_from(&config_path);
404-
assert_eq!(config.rpc_url("eip155:1"), Some("https://custom-eth.rpc"));
429+
assert_eq!(
430+
config.rpc_url("eip155:1"),
431+
Some("https://custom-eth.rpc".to_string())
432+
);
405433
assert_eq!(
406434
config.rpc_url("eip155:137"),
407-
Some("https://polygon-rpc.com")
435+
Some("https://polygon-rpc.com".to_string())
408436
);
409437
assert_eq!(config.vault_path, PathBuf::from("/tmp/custom-vault"));
410438
}
@@ -536,7 +564,7 @@ mod tests {
536564
// Defaults still present via legacy rpc
537565
assert_eq!(
538566
config.rpc_url("eip155:137"),
539-
Some("https://polygon-rpc.com")
567+
Some("https://polygon-rpc.com".to_string())
540568
);
541569
}
542570

@@ -555,7 +583,10 @@ mod tests {
555583

556584
let config = Config::load_or_default_from(&config_path);
557585
// Old flat rpc is preserved
558-
assert_eq!(config.rpc_url("eip155:1"), Some("https://old-custom.eth"));
586+
assert_eq!(
587+
config.rpc_url("eip155:1"),
588+
Some("https://old-custom.eth".to_string())
589+
);
559590
// rpc_config is None since old format doesn't have it
560591
assert!(config.rpc_config.is_none());
561592
}
@@ -589,7 +620,10 @@ mod tests {
589620
Some("https://new-profile.eth".to_string())
590621
);
591622
// But legacy rpc is still accessible directly
592-
assert_eq!(config.rpc_url("eip155:1"), Some("https://legacy.eth"));
623+
assert_eq!(
624+
config.rpc_url("eip155:1"),
625+
Some("https://legacy.eth".to_string())
626+
);
593627
}
594628

595629
#[test]
@@ -632,6 +666,20 @@ mod tests {
632666
assert_eq!(config.active_profile(), None);
633667
}
634668

669+
#[test]
670+
fn test_remove_last_chain_clears_active() {
671+
// Regression: removing the last chain from the active profile via
672+
// remove_profile_endpoint should clear active_profile.
673+
let mut config = Config::default();
674+
config.upsert_profile_endpoint("dev", "eip155:1", "https://dev-eth.example.com".into());
675+
config.set_active_profile(Some("dev".into()));
676+
677+
assert_eq!(config.active_profile(), Some("dev"));
678+
assert!(config.remove_profile_endpoint("dev", "eip155:1"));
679+
assert!(config.profile("dev").is_none());
680+
assert_eq!(config.active_profile(), None);
681+
}
682+
635683
#[test]
636684
fn test_delete_profile_nonexistent() {
637685
let mut config = Config::default();
@@ -700,4 +748,48 @@ mod tests {
700748
let config = Config::default();
701749
assert!(config.profile_names().collect::<Vec<_>>().is_empty());
702750
}
751+
752+
#[test]
753+
fn test_save_config_does_not_bake_in_defaults() {
754+
// Regression: saving a config with only profile-based RPC should NOT
755+
// write the built-in defaults into the rpc field.
756+
let dir = tempfile::tempdir().unwrap();
757+
let config_path = dir.path().join("config.json");
758+
759+
// Build a config that only has a profile (no global rpc overrides)
760+
let mut config = Config::load_or_default_from(&config_path);
761+
config.upsert_profile_endpoint("dev", "eip155:1", "https://dev-eth.example.com".into());
762+
config.set_active_profile(Some("dev".into()));
763+
764+
// Serialize and write to disk (as CLI save does)
765+
let json = serde_json::to_string(&config).unwrap();
766+
std::fs::write(&config_path, &json).unwrap();
767+
768+
// Read back as Value and verify rpc field is absent/empty
769+
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
770+
771+
// rpc field must be absent or empty — no baked-in defaults
772+
let rpc = parsed.get("rpc");
773+
assert!(
774+
rpc.is_none() || rpc == Some(&serde_json::Value::Object(Default::default())),
775+
"rpc field should be absent or empty, got: {:?}",
776+
rpc
777+
);
778+
779+
// profiles should be present
780+
assert!(
781+
parsed
782+
.get("rpc_config")
783+
.and_then(|c| c.get("profiles"))
784+
.is_some(),
785+
"profiles should be saved"
786+
);
787+
788+
// Reload from disk and verify profile still works
789+
let reloaded = Config::load_or_default_from(&config_path);
790+
assert_eq!(
791+
reloaded.profile_rpc_url("eip155:1"),
792+
Some("https://dev-eth.example.com")
793+
);
794+
}
703795
}

0 commit comments

Comments
 (0)