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_yaml::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_yaml::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::Chain(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 eprintln!(
213 " Using '{}' → {} ({})",
214 label_display, watched.address, watched.chain
215 );
216 return Ok(Some((watched.address.clone(), watched.chain.clone())));
217 }
218 let available: Vec<String> = address_book
220 .addresses
221 .iter()
222 .filter_map(|a| a.label.clone())
223 .collect();
224 let suggestion = if available.is_empty() {
225 "Your address book is empty. Add entries with `scope address-book add`.".to_string()
226 } else {
227 format!(
228 "Available labels: {}",
229 available
230 .iter()
231 .map(|l| format!("@{}", l))
232 .collect::<Vec<_>>()
233 .join(", ")
234 )
235 };
236 return Err(crate::error::ScopeError::NotFound(format!(
237 "No address book entry matching '@{}'.\n {}",
238 label, suggestion
239 )));
240 }
241
242 if let Some(watched) = address_book.find_address(input) {
244 if let Some(ref label) = watched.label {
245 tracing::debug!(
246 "Address book match by address for '{}' ({})",
247 label,
248 watched.chain
249 );
250 }
251 return Ok(Some((watched.address.clone(), watched.chain.clone())));
252 }
253
254 Ok(None)
255}
256
257#[cfg(test)]
262mod tests {
263 use super::*;
264 use tempfile::TempDir;
265
266 fn create_test_address_book() -> AddressBook {
267 AddressBook {
268 addresses: vec![
269 WatchedAddress {
270 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
271 label: Some("Main Wallet".to_string()),
272 chain: "ethereum".to_string(),
273 tags: vec!["personal".to_string()],
274 added_at: 1700000000,
275 },
276 WatchedAddress {
277 address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
278 label: None,
279 chain: "polygon".to_string(),
280 tags: vec![],
281 added_at: 1700000001,
282 },
283 ],
284 }
285 }
286
287 #[test]
288 fn test_address_book_default() {
289 let address_book = AddressBook::default();
290 assert!(address_book.addresses.is_empty());
291 }
292
293 #[test]
294 fn test_address_book_add_address() {
295 let mut address_book = AddressBook::default();
296
297 let watched = WatchedAddress {
298 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
299 label: Some("Test".to_string()),
300 chain: "ethereum".to_string(),
301 tags: vec![],
302 added_at: 0,
303 };
304
305 let result = address_book.add_address(watched);
306 assert!(result.is_ok());
307 assert_eq!(address_book.addresses.len(), 1);
308 }
309
310 #[test]
311 fn test_address_book_add_duplicate_fails() {
312 let mut address_book = AddressBook::default();
313
314 let watched1 = WatchedAddress {
315 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
316 label: Some("First".to_string()),
317 chain: "ethereum".to_string(),
318 tags: vec![],
319 added_at: 0,
320 };
321
322 let watched2 = WatchedAddress {
323 address: "0x742d35CC6634C0532925A3b844Bc9e7595f1b3c2".to_string(), label: Some("Second".to_string()),
325 chain: "ethereum".to_string(),
326 tags: vec![],
327 added_at: 0,
328 };
329
330 address_book.add_address(watched1).unwrap();
331 let result = address_book.add_address(watched2);
332
333 assert!(result.is_err());
334 assert!(
335 result
336 .unwrap_err()
337 .to_string()
338 .contains("already in address book")
339 );
340 }
341
342 #[test]
343 fn test_address_book_remove_address() {
344 let mut address_book = create_test_address_book();
345 let original_len = address_book.addresses.len();
346
347 let removed = address_book
348 .remove_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2")
349 .unwrap();
350
351 assert!(removed);
352 assert_eq!(address_book.addresses.len(), original_len - 1);
353 }
354
355 #[test]
356 fn test_address_book_remove_nonexistent() {
357 let mut address_book = create_test_address_book();
358 let original_len = address_book.addresses.len();
359
360 let removed = address_book.remove_address("0xnonexistent").unwrap();
361
362 assert!(!removed);
363 assert_eq!(address_book.addresses.len(), original_len);
364 }
365
366 #[test]
367 fn test_address_book_find_address() {
368 let address_book = create_test_address_book();
369
370 let found = address_book.find_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
371 assert!(found.is_some());
372 assert_eq!(found.unwrap().label, Some("Main Wallet".to_string()));
373
374 let not_found = address_book.find_address("0xnonexistent");
375 assert!(not_found.is_none());
376 }
377
378 #[test]
379 fn test_address_book_find_address_case_insensitive() {
380 let address_book = create_test_address_book();
381
382 let found = address_book.find_address("0x742D35CC6634C0532925A3B844BC9E7595F1B3C2");
383 assert!(found.is_some());
384 }
385
386 #[test]
387 fn test_address_book_save_and_load() {
388 let temp_dir = TempDir::new().unwrap();
389 let data_dir = temp_dir.path().to_path_buf();
390
391 let address_book = create_test_address_book();
392 address_book.save(&data_dir).unwrap();
393
394 let loaded = AddressBook::load(&data_dir).unwrap();
395 assert_eq!(loaded.addresses.len(), address_book.addresses.len());
396 assert_eq!(
397 loaded.addresses[0].address,
398 address_book.addresses[0].address
399 );
400 }
401
402 #[test]
403 fn test_address_book_load_nonexistent_returns_default() {
404 let temp_dir = TempDir::new().unwrap();
405 let data_dir = temp_dir.path().to_path_buf();
406
407 let address_book = AddressBook::load(&data_dir).unwrap();
408 assert!(address_book.addresses.is_empty());
409 }
410
411 #[test]
412 fn test_watched_address_serialization() {
413 let watched = WatchedAddress {
414 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
415 label: Some("Test".to_string()),
416 chain: "ethereum".to_string(),
417 tags: vec!["tag1".to_string(), "tag2".to_string()],
418 added_at: 1700000000,
419 };
420
421 let json = serde_json::to_string(&watched).unwrap();
422 assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
423 assert!(json.contains("Test"));
424 assert!(json.contains("tag1"));
425
426 let deserialized: WatchedAddress = serde_json::from_str(&json).unwrap();
427 assert_eq!(deserialized.address, watched.address);
428 assert_eq!(deserialized.tags.len(), 2);
429 }
430
431 #[test]
432 fn test_address_book_summary_serialization() {
433 let summary = AddressBookSummary {
434 address_count: 2,
435 balances_by_chain: HashMap::new(),
436 total_usd: Some(10000.0),
437 addresses: vec![AddressSummary {
438 address: "0x123".to_string(),
439 label: Some("Test".to_string()),
440 chain: "ethereum".to_string(),
441 balance: "1.5".to_string(),
442 usd: Some(5000.0),
443 tokens: vec![],
444 }],
445 };
446
447 let json = serde_json::to_string(&summary).unwrap();
448 assert!(json.contains("10000"));
449 assert!(json.contains("0x123"));
450 }
451
452 #[test]
453 fn test_chain_balance_serialization() {
454 let balance = ChainBalance {
455 native_balance: "10.5".to_string(),
456 symbol: "ETH".to_string(),
457 usd: Some(35000.0),
458 };
459
460 let json = serde_json::to_string(&balance).unwrap();
461 assert!(json.contains("10.5"));
462 assert!(json.contains("ETH"));
463 assert!(json.contains("35000"));
464 }
465}