1use crate::config::Config;
7use crate::error::{Result, ScopeError};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct WatchedAddress {
15 pub address: String,
17
18 #[serde(skip_serializing_if = "Option::is_none")]
20 pub label: Option<String>,
21
22 pub chain: String,
24
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub tags: Vec<String>,
28
29 pub added_at: u64,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct AddressBook {
36 pub addresses: Vec<WatchedAddress>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct AddressBookSummary {
43 pub address_count: usize,
45
46 pub balances_by_chain: HashMap<String, ChainBalance>,
48
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub total_usd: Option<f64>,
52
53 pub addresses: Vec<AddressSummary>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ChainBalance {
60 pub native_balance: String,
62
63 pub symbol: String,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub usd: Option<f64>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct AddressSummary {
74 pub address: String,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub label: Option<String>,
80
81 pub chain: String,
83
84 pub balance: String,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub usd: Option<f64>,
90
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub tokens: Vec<TokenSummary>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct TokenSummary {
99 pub contract_address: String,
101 pub balance: String,
103 pub decimals: u8,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub symbol: Option<String>,
108}
109
110impl AddressBook {
111 pub fn load(data_dir: &std::path::Path) -> Result<Self> {
113 let path = data_dir.join("address_book.yaml");
114
115 if !path.exists() {
116 return Ok(Self::default());
117 }
118
119 let contents = std::fs::read_to_string(&path)?;
120 let address_book: AddressBook = serde_saphyr::from_str(&contents)
121 .map_err(|e| ScopeError::Config(crate::error::ConfigError::Parse { source: e }))?;
122
123 Ok(address_book)
124 }
125
126 pub fn save(&self, data_dir: &PathBuf) -> Result<()> {
128 std::fs::create_dir_all(data_dir)?;
129
130 let path = data_dir.join("address_book.yaml");
131 let contents = serde_saphyr::to_string(self)
132 .map_err(|e| ScopeError::Export(format!("Failed to serialize address book: {}", e)))?;
133
134 std::fs::write(&path, contents)?;
135 Ok(())
136 }
137
138 pub fn add_address(&mut self, watched: WatchedAddress) -> Result<()> {
140 if self
142 .addresses
143 .iter()
144 .any(|a| a.address.to_lowercase() == watched.address.to_lowercase())
145 {
146 return Err(ScopeError::InvalidInput(format!(
147 "Address already in address book: {}",
148 watched.address
149 )));
150 }
151
152 self.addresses.push(watched);
153 Ok(())
154 }
155
156 pub fn remove_address(&mut self, address: &str) -> Result<bool> {
158 let original_len = self.addresses.len();
159 self.addresses
160 .retain(|a| a.address.to_lowercase() != address.to_lowercase());
161
162 Ok(self.addresses.len() < original_len)
163 }
164
165 pub fn find_address(&self, address: &str) -> Option<&WatchedAddress> {
167 self.addresses
168 .iter()
169 .find(|a| a.address.to_lowercase() == address.to_lowercase())
170 }
171
172 pub fn find_by_label(&self, label: &str) -> Option<&WatchedAddress> {
177 let needle = label.trim().to_lowercase();
178 self.addresses.iter().find(|a| {
179 a.label
180 .as_ref()
181 .is_some_and(|l| l.trim().to_lowercase() == needle)
182 })
183 }
184}
185
186pub fn resolve_address_book_input(
199 input: &str,
200 config: &Config,
201) -> crate::error::Result<Option<(String, String)>> {
202 let data_dir = config.data_dir();
203 let address_book = match AddressBook::load(&data_dir) {
204 Ok(ab) => ab,
205 Err(_) => return Ok(None),
206 };
207
208 if let Some(label) = input.strip_prefix('@') {
210 if let Some(watched) = address_book.find_by_label(label) {
211 let label_display = watched.label.as_deref().unwrap_or(label);
212 crate::notice!(
213 " Using '{}' → {} ({})",
214 label_display,
215 watched.address,
216 watched.chain
217 );
218 return Ok(Some((watched.address.clone(), watched.chain.clone())));
219 }
220 let available: Vec<String> = address_book
222 .addresses
223 .iter()
224 .filter_map(|a| a.label.clone())
225 .collect();
226 let suggestion = if available.is_empty() {
227 "Your address book is empty. Add entries with `scope address-book add`.".to_string()
228 } else {
229 format!(
230 "Available labels: {}",
231 available
232 .iter()
233 .map(|l| format!("@{}", l))
234 .collect::<Vec<_>>()
235 .join(", ")
236 )
237 };
238 return Err(crate::error::ScopeError::NotFound(format!(
239 "No address book entry matching '@{}'.\n {}",
240 label, suggestion
241 )));
242 }
243
244 if let Some(watched) = address_book.find_address(input) {
246 if let Some(ref label) = watched.label {
247 tracing::debug!(
248 "Address book match by address for '{}' ({})",
249 label,
250 watched.chain
251 );
252 }
253 return Ok(Some((watched.address.clone(), watched.chain.clone())));
254 }
255
256 Ok(None)
257}
258
259#[cfg(test)]
264mod tests {
265 use super::*;
266 use tempfile::TempDir;
267
268 fn create_test_address_book() -> AddressBook {
269 AddressBook {
270 addresses: vec![
271 WatchedAddress {
272 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
273 label: Some("Main Wallet".to_string()),
274 chain: "ethereum".to_string(),
275 tags: vec!["personal".to_string()],
276 added_at: 1700000000,
277 },
278 WatchedAddress {
279 address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
280 label: None,
281 chain: "polygon".to_string(),
282 tags: vec![],
283 added_at: 1700000001,
284 },
285 ],
286 }
287 }
288
289 #[test]
290 fn test_address_book_default() {
291 let address_book = AddressBook::default();
292 assert!(address_book.addresses.is_empty());
293 }
294
295 #[test]
296 fn test_address_book_add_address() {
297 let mut address_book = AddressBook::default();
298
299 let watched = WatchedAddress {
300 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
301 label: Some("Test".to_string()),
302 chain: "ethereum".to_string(),
303 tags: vec![],
304 added_at: 0,
305 };
306
307 let result = address_book.add_address(watched);
308 assert!(result.is_ok());
309 assert_eq!(address_book.addresses.len(), 1);
310 }
311
312 #[test]
313 fn test_address_book_add_duplicate_fails() {
314 let mut address_book = AddressBook::default();
315
316 let watched1 = WatchedAddress {
317 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
318 label: Some("First".to_string()),
319 chain: "ethereum".to_string(),
320 tags: vec![],
321 added_at: 0,
322 };
323
324 let watched2 = WatchedAddress {
325 address: "0x742d35CC6634C0532925A3b844Bc9e7595f1b3c2".to_string(), label: Some("Second".to_string()),
327 chain: "ethereum".to_string(),
328 tags: vec![],
329 added_at: 0,
330 };
331
332 address_book.add_address(watched1).unwrap();
333 let result = address_book.add_address(watched2);
334
335 assert!(result.is_err());
336 assert!(
337 result
338 .unwrap_err()
339 .to_string()
340 .contains("already in address book")
341 );
342 }
343
344 #[test]
345 fn test_address_book_remove_address() {
346 let mut address_book = create_test_address_book();
347 let original_len = address_book.addresses.len();
348
349 let removed = address_book
350 .remove_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2")
351 .unwrap();
352
353 assert!(removed);
354 assert_eq!(address_book.addresses.len(), original_len - 1);
355 }
356
357 #[test]
358 fn test_address_book_remove_nonexistent() {
359 let mut address_book = create_test_address_book();
360 let original_len = address_book.addresses.len();
361
362 let removed = address_book.remove_address("0xnonexistent").unwrap();
363
364 assert!(!removed);
365 assert_eq!(address_book.addresses.len(), original_len);
366 }
367
368 #[test]
369 fn test_address_book_find_address() {
370 let address_book = create_test_address_book();
371
372 let found = address_book.find_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
373 assert!(found.is_some());
374 assert_eq!(found.unwrap().label, Some("Main Wallet".to_string()));
375
376 let not_found = address_book.find_address("0xnonexistent");
377 assert!(not_found.is_none());
378 }
379
380 #[test]
381 fn test_address_book_find_address_case_insensitive() {
382 let address_book = create_test_address_book();
383
384 let found = address_book.find_address("0x742D35CC6634C0532925A3B844BC9E7595F1B3C2");
385 assert!(found.is_some());
386 }
387
388 #[test]
389 fn test_address_book_save_and_load() {
390 let temp_dir = TempDir::new().unwrap();
391 let data_dir = temp_dir.path().to_path_buf();
392
393 let address_book = create_test_address_book();
394 address_book.save(&data_dir).unwrap();
395
396 let loaded = AddressBook::load(&data_dir).unwrap();
397 assert_eq!(loaded.addresses.len(), address_book.addresses.len());
398 assert_eq!(
399 loaded.addresses[0].address,
400 address_book.addresses[0].address
401 );
402 }
403
404 #[test]
405 fn test_address_book_load_nonexistent_returns_default() {
406 let temp_dir = TempDir::new().unwrap();
407 let data_dir = temp_dir.path().to_path_buf();
408
409 let address_book = AddressBook::load(&data_dir).unwrap();
410 assert!(address_book.addresses.is_empty());
411 }
412
413 #[test]
414 fn test_watched_address_serialization() {
415 let watched = WatchedAddress {
416 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
417 label: Some("Test".to_string()),
418 chain: "ethereum".to_string(),
419 tags: vec!["tag1".to_string(), "tag2".to_string()],
420 added_at: 1700000000,
421 };
422
423 let json = serde_json::to_string(&watched).unwrap();
424 assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
425 assert!(json.contains("Test"));
426 assert!(json.contains("tag1"));
427
428 let deserialized: WatchedAddress = serde_json::from_str(&json).unwrap();
429 assert_eq!(deserialized.address, watched.address);
430 assert_eq!(deserialized.tags.len(), 2);
431 }
432
433 #[test]
434 fn test_address_book_summary_serialization() {
435 let summary = AddressBookSummary {
436 address_count: 2,
437 balances_by_chain: HashMap::new(),
438 total_usd: Some(10000.0),
439 addresses: vec![AddressSummary {
440 address: "0x123".to_string(),
441 label: Some("Test".to_string()),
442 chain: "ethereum".to_string(),
443 balance: "1.5".to_string(),
444 usd: Some(5000.0),
445 tokens: vec![],
446 }],
447 };
448
449 let json = serde_json::to_string(&summary).unwrap();
450 assert!(json.contains("10000"));
451 assert!(json.contains("0x123"));
452 }
453
454 #[test]
455 fn test_chain_balance_serialization() {
456 let balance = ChainBalance {
457 native_balance: "10.5".to_string(),
458 symbol: "ETH".to_string(),
459 usd: Some(35000.0),
460 };
461
462 let json = serde_json::to_string(&balance).unwrap();
463 assert!(json.contains("10.5"));
464 assert!(json.contains("ETH"));
465 assert!(json.contains("35000"));
466 }
467}