1use crate::chains::{ChainClientFactory, native_symbol};
23use crate::config::{Config, OutputFormat};
24use crate::error::{Result, ScopeError};
25use clap::{Args, Subcommand};
26use serde::{Deserialize, Serialize};
27use std::collections::HashMap;
28use std::path::PathBuf;
29
30#[derive(Debug, Clone, Args)]
32pub struct AddressBookArgs {
33 #[command(subcommand)]
35 pub command: AddressBookCommands,
36
37 #[arg(short, long, global = true, value_name = "FORMAT")]
39 pub format: Option<OutputFormat>,
40}
41
42#[derive(Debug, Clone, Subcommand)]
44pub enum AddressBookCommands {
45 Add(AddArgs),
47
48 Remove(RemoveArgs),
50
51 List,
53
54 Summary(SummaryArgs),
56}
57
58#[derive(Debug, Clone, Args)]
60pub struct AddArgs {
61 #[arg(value_name = "ADDRESS")]
63 pub address: String,
64
65 #[arg(short, long)]
67 pub label: Option<String>,
68
69 #[arg(short, long, default_value = "ethereum")]
71 pub chain: String,
72
73 #[arg(short, long, value_delimiter = ',')]
75 pub tags: Vec<String>,
76}
77
78#[derive(Debug, Clone, Args)]
80pub struct RemoveArgs {
81 #[arg(value_name = "ADDRESS")]
83 pub address: String,
84}
85
86#[derive(Debug, Clone, Args)]
88pub struct SummaryArgs {
89 #[arg(short, long)]
91 pub chain: Option<String>,
92
93 #[arg(short, long)]
95 pub tag: Option<String>,
96
97 #[arg(long)]
99 pub include_tokens: bool,
100
101 #[arg(long, value_name = "PATH")]
103 pub report: Option<std::path::PathBuf>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct WatchedAddress {
109 pub address: String,
111
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub label: Option<String>,
115
116 pub chain: String,
118
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub tags: Vec<String>,
122
123 pub added_at: u64,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, Default)]
129pub struct AddressBook {
130 pub addresses: Vec<WatchedAddress>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct AddressBookSummary {
137 pub address_count: usize,
139
140 pub balances_by_chain: HashMap<String, ChainBalance>,
142
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub total_usd: Option<f64>,
146
147 pub addresses: Vec<AddressSummary>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ChainBalance {
154 pub native_balance: String,
156
157 pub symbol: String,
159
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub usd: Option<f64>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct AddressSummary {
168 pub address: String,
170
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub label: Option<String>,
174
175 pub chain: String,
177
178 pub balance: String,
180
181 #[serde(skip_serializing_if = "Option::is_none")]
183 pub usd: Option<f64>,
184
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub tokens: Vec<TokenSummary>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct TokenSummary {
193 pub contract_address: String,
195 pub balance: String,
197 pub decimals: u8,
199 #[serde(skip_serializing_if = "Option::is_none")]
201 pub symbol: Option<String>,
202}
203
204impl AddressBook {
205 pub fn load(data_dir: &std::path::Path) -> Result<Self> {
207 let path = data_dir.join("address_book.yaml");
208
209 if !path.exists() {
210 return Ok(Self::default());
211 }
212
213 let contents = std::fs::read_to_string(&path)?;
214 let address_book: AddressBook = serde_yaml::from_str(&contents)
215 .map_err(|e| ScopeError::Config(crate::error::ConfigError::Parse { source: e }))?;
216
217 Ok(address_book)
218 }
219
220 pub fn save(&self, data_dir: &PathBuf) -> Result<()> {
222 std::fs::create_dir_all(data_dir)?;
223
224 let path = data_dir.join("address_book.yaml");
225 let contents = serde_yaml::to_string(self)
226 .map_err(|e| ScopeError::Export(format!("Failed to serialize address book: {}", e)))?;
227
228 std::fs::write(&path, contents)?;
229 Ok(())
230 }
231
232 pub fn add_address(&mut self, watched: WatchedAddress) -> Result<()> {
234 if self
236 .addresses
237 .iter()
238 .any(|a| a.address.to_lowercase() == watched.address.to_lowercase())
239 {
240 return Err(ScopeError::Chain(format!(
241 "Address already in address book: {}",
242 watched.address
243 )));
244 }
245
246 self.addresses.push(watched);
247 Ok(())
248 }
249
250 pub fn remove_address(&mut self, address: &str) -> Result<bool> {
252 let original_len = self.addresses.len();
253 self.addresses
254 .retain(|a| a.address.to_lowercase() != address.to_lowercase());
255
256 Ok(self.addresses.len() < original_len)
257 }
258
259 pub fn find_address(&self, address: &str) -> Option<&WatchedAddress> {
261 self.addresses
262 .iter()
263 .find(|a| a.address.to_lowercase() == address.to_lowercase())
264 }
265
266 pub fn find_by_label(&self, label: &str) -> Option<&WatchedAddress> {
271 let needle = label.trim().to_lowercase();
272 self.addresses.iter().find(|a| {
273 a.label
274 .as_ref()
275 .is_some_and(|l| l.trim().to_lowercase() == needle)
276 })
277 }
278}
279
280pub fn resolve_address_book_input(
293 input: &str,
294 config: &Config,
295) -> crate::error::Result<Option<(String, String)>> {
296 let data_dir = config.data_dir();
297 let address_book = match AddressBook::load(&data_dir) {
298 Ok(ab) => ab,
299 Err(_) => return Ok(None),
300 };
301
302 if let Some(label) = input.strip_prefix('@') {
304 if let Some(watched) = address_book.find_by_label(label) {
305 let label_display = watched.label.as_deref().unwrap_or(label);
306 eprintln!(
307 " Using '{}' → {} ({})",
308 label_display, watched.address, watched.chain
309 );
310 return Ok(Some((watched.address.clone(), watched.chain.clone())));
311 }
312 let available: Vec<String> = address_book
314 .addresses
315 .iter()
316 .filter_map(|a| a.label.clone())
317 .collect();
318 let suggestion = if available.is_empty() {
319 "Your address book is empty. Add entries with `scope address-book add`.".to_string()
320 } else {
321 format!(
322 "Available labels: {}",
323 available
324 .iter()
325 .map(|l| format!("@{}", l))
326 .collect::<Vec<_>>()
327 .join(", ")
328 )
329 };
330 return Err(crate::error::ScopeError::NotFound(format!(
331 "No address book entry matching '@{}'.\n {}",
332 label, suggestion
333 )));
334 }
335
336 if let Some(watched) = address_book.find_address(input) {
338 if let Some(ref label) = watched.label {
339 tracing::debug!(
340 "Address book match by address for '{}' ({})",
341 label,
342 watched.chain
343 );
344 }
345 return Ok(Some((watched.address.clone(), watched.chain.clone())));
346 }
347
348 Ok(None)
349}
350
351pub async fn run(
353 args: AddressBookArgs,
354 config: &Config,
355 clients: &dyn ChainClientFactory,
356) -> Result<()> {
357 let data_dir = config.data_dir();
358 let format = args.format.unwrap_or(config.output.format);
359
360 match args.command {
361 AddressBookCommands::Add(add_args) => run_add(add_args, &data_dir).await,
362 AddressBookCommands::Remove(remove_args) => run_remove(remove_args, &data_dir).await,
363 AddressBookCommands::List => run_list(&data_dir, format).await,
364 AddressBookCommands::Summary(summary_args) => {
365 run_summary(summary_args, &data_dir, format, clients).await
366 }
367 }
368}
369
370async fn run_add(args: AddArgs, data_dir: &PathBuf) -> Result<()> {
371 tracing::info!(address = %args.address, "Adding address to address book");
372
373 let mut address_book = AddressBook::load(data_dir)?;
374
375 let watched = WatchedAddress {
376 address: args.address.clone(),
377 label: args.label.clone(),
378 chain: args.chain.clone(),
379 tags: args.tags.clone(),
380 added_at: std::time::SystemTime::now()
381 .duration_since(std::time::UNIX_EPOCH)
382 .unwrap_or_default()
383 .as_secs(),
384 };
385
386 address_book.add_address(watched)?;
387 address_book.save(data_dir)?;
388
389 println!(
390 "Added {} to address book{}",
391 args.address,
392 args.label
393 .map(|l| format!(" as '{}'", l))
394 .unwrap_or_default()
395 );
396
397 Ok(())
398}
399
400async fn run_remove(args: RemoveArgs, data_dir: &PathBuf) -> Result<()> {
401 tracing::info!(address = %args.address, "Removing address from address book");
402
403 let mut address_book = AddressBook::load(data_dir)?;
404 let removed = address_book.remove_address(&args.address)?;
405
406 if removed {
407 address_book.save(data_dir)?;
408 println!("Removed {} from address book", args.address);
409 } else {
410 println!("Address not found in address book: {}", args.address);
411 }
412
413 Ok(())
414}
415
416async fn run_list(data_dir: &std::path::Path, format: OutputFormat) -> Result<()> {
417 let address_book = AddressBook::load(data_dir)?;
418
419 if address_book.addresses.is_empty() {
420 println!("Address book is empty. Add addresses with 'scope address-book add <address>'");
421 return Ok(());
422 }
423
424 match format {
425 OutputFormat::Json => {
426 let json = serde_json::to_string_pretty(&address_book.addresses)?;
427 println!("{}", json);
428 }
429 OutputFormat::Csv => {
430 println!("address,label,chain,tags");
431 for addr in &address_book.addresses {
432 println!(
433 "{},{},{},{}",
434 addr.address,
435 addr.label.as_deref().unwrap_or(""),
436 addr.chain,
437 addr.tags.join(";")
438 );
439 }
440 }
441 OutputFormat::Table => {
442 println!("Address Book");
443 println!("===================");
444 for addr in &address_book.addresses {
445 println!(
446 " {} ({}) - {}{}",
447 addr.address,
448 addr.chain,
449 addr.label.as_deref().unwrap_or("No label"),
450 if addr.tags.is_empty() {
451 String::new()
452 } else {
453 format!(" [{}]", addr.tags.join(", "))
454 }
455 );
456 }
457 println!("\nTotal: {} addresses", address_book.addresses.len());
458 }
459 OutputFormat::Markdown => {
460 let mut md = "# Address Book\n\n".to_string();
461 md.push_str("| Address | Chain | Label | Tags |\n|---------|-------|-------|------|\n");
462 for addr in &address_book.addresses {
463 let tags = if addr.tags.is_empty() {
464 "-".to_string()
465 } else {
466 addr.tags.join(", ")
467 };
468 md.push_str(&format!(
469 "| `{}` | {} | {} | {} |\n",
470 addr.address,
471 addr.chain,
472 addr.label.as_deref().unwrap_or("-"),
473 tags
474 ));
475 }
476 md.push_str(&format!(
477 "\n**Total:** {} addresses\n",
478 address_book.addresses.len()
479 ));
480 println!("{}", md);
481 }
482 }
483
484 Ok(())
485}
486
487async fn run_summary(
488 args: SummaryArgs,
489 data_dir: &std::path::Path,
490 format: OutputFormat,
491 clients: &dyn ChainClientFactory,
492) -> Result<()> {
493 let address_book = AddressBook::load(data_dir)?;
494
495 if address_book.addresses.is_empty() {
496 println!("Address book is empty. Add addresses with 'scope address-book add <address>'");
497 return Ok(());
498 }
499
500 let filtered: Vec<_> = address_book
502 .addresses
503 .iter()
504 .filter(|a| args.chain.as_ref().is_none_or(|c| &a.chain == c))
505 .filter(|a| args.tag.as_ref().is_none_or(|t| a.tags.contains(t)))
506 .collect();
507
508 let mut address_summaries = Vec::new();
510 let mut balances_by_chain: HashMap<String, ChainBalance> = HashMap::new();
511
512 for watched in &filtered {
513 let (balance, tokens) = fetch_address_balance(
514 &watched.address,
515 &watched.chain,
516 clients,
517 args.include_tokens,
518 )
519 .await;
520
521 if let Some(chain_bal) = balances_by_chain.get_mut(&watched.chain) {
523 let _ = chain_bal;
526 } else {
527 balances_by_chain.insert(
528 watched.chain.clone(),
529 ChainBalance {
530 native_balance: balance.clone(),
531 symbol: native_symbol(&watched.chain).to_string(),
532 usd: None,
533 },
534 );
535 }
536
537 address_summaries.push(AddressSummary {
538 address: watched.address.clone(),
539 label: watched.label.clone(),
540 chain: watched.chain.clone(),
541 balance,
542 usd: None,
543 tokens,
544 });
545 }
546
547 let summary = AddressBookSummary {
548 address_count: filtered.len(),
549 balances_by_chain,
550 total_usd: None,
551 addresses: address_summaries,
552 };
553
554 match format {
555 OutputFormat::Json => {
556 let json = serde_json::to_string_pretty(&summary)?;
557 println!("{}", json);
558 }
559 OutputFormat::Csv => {
560 println!("address,label,chain,balance,usd");
561 for addr in &summary.addresses {
562 println!(
563 "{},{},{},{},{}",
564 addr.address,
565 addr.label.as_deref().unwrap_or(""),
566 addr.chain,
567 addr.balance,
568 addr.usd.map_or(String::new(), |u| format!("{:.2}", u))
569 );
570 }
571 }
572 OutputFormat::Table => {
573 println!("Address Book Summary");
574 println!("=================");
575 println!("Addresses: {}", summary.address_count);
576 println!();
577
578 for addr in &summary.addresses {
579 println!(
580 " {} ({}) - {} {}",
581 addr.label.as_deref().unwrap_or(&addr.address),
582 addr.chain,
583 addr.balance,
584 addr.usd.map_or(String::new(), |u| format!("(${:.2})", u))
585 );
586
587 for token in &addr.tokens {
589 let addr_short = if token.contract_address.len() >= 8 {
590 &token.contract_address[..8]
591 } else {
592 &token.contract_address
593 };
594 let symbol = token.symbol.as_deref().unwrap_or(addr_short);
595 println!(" └─ {} {}", token.balance, symbol);
596 }
597 }
598
599 if let Some(total) = summary.total_usd {
600 println!();
601 println!("Total Value: ${:.2}", total);
602 }
603 }
604 OutputFormat::Markdown => {
605 let md = address_book_summary_to_markdown(&summary);
606 println!("{}", md);
607 }
608 }
609
610 if let Some(ref report_path) = args.report {
612 let md = address_book_summary_to_markdown(&summary);
613 std::fs::write(report_path, md)?;
614 println!("\nReport saved to: {}", report_path.display());
615 }
616
617 Ok(())
618}
619
620fn address_book_summary_to_markdown(summary: &AddressBookSummary) -> String {
622 let mut md = format!(
623 "# Address Book Report\n\n\
624 **Generated:** {} \n\
625 **Addresses:** {} \n\n",
626 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
627 summary.address_count
628 );
629
630 if let Some(total) = summary.total_usd {
631 md.push_str(&format!("**Total Value (USD):** ${:.2} \n\n", total));
632 }
633
634 md.push_str("## Allocation by Chain\n\n");
635 md.push_str(
636 "| Chain | Native Balance | Symbol | USD |\n|-------|----------------|--------|-----|\n",
637 );
638 for (chain, bal) in &summary.balances_by_chain {
639 let usd = bal
640 .usd
641 .map(|u| format!("${:.2}", u))
642 .unwrap_or_else(|| "-".to_string());
643 md.push_str(&format!(
644 "| {} | {} | {} | {} |\n",
645 chain, bal.native_balance, bal.symbol, usd
646 ));
647 }
648
649 md.push_str("\n## Addresses\n\n");
650 md.push_str("| Address | Label | Chain | Balance | USD | Tokens |\n");
651 md.push_str("|---------|-------|-------|---------|-----|--------|\n");
652 for addr in &summary.addresses {
653 let label = addr.label.as_deref().unwrap_or("-");
654 let usd = addr
655 .usd
656 .map(|u| format!("${:.2}", u))
657 .unwrap_or_else(|| "-".to_string());
658 let token_list: String = addr
659 .tokens
660 .iter()
661 .map(|t| t.symbol.as_deref().unwrap_or(&t.contract_address))
662 .take(3)
663 .collect::<Vec<_>>()
664 .join(", ");
665 let tokens_display = if addr.tokens.len() > 3 {
666 format!("{} (+{})", token_list, addr.tokens.len() - 3)
667 } else {
668 token_list
669 };
670 md.push_str(&format!(
671 "| `{}` | {} | {} | {} | {} | {} |\n",
672 addr.address,
673 label,
674 addr.chain,
675 addr.balance,
676 usd,
677 if tokens_display.is_empty() {
678 "-"
679 } else {
680 &tokens_display
681 }
682 ));
683 }
684
685 md.push_str(&crate::display::report::report_footer());
686 md
687}
688
689async fn fetch_address_balance(
691 address: &str,
692 chain: &str,
693 clients: &dyn ChainClientFactory,
694 _include_tokens: bool,
695) -> (String, Vec<TokenSummary>) {
696 let client = match clients.create_chain_client(chain) {
697 Ok(c) => c,
698 Err(e) => {
699 eprintln!(" ⚠ Unsupported chain: {}", chain);
700 tracing::debug!(error = %e, chain = %chain, "Failed to create chain client");
701 return ("Error".to_string(), vec![]);
702 }
703 };
704
705 let native_balance = match client.get_balance(address).await {
707 Ok(bal) => bal.formatted,
708 Err(e) => {
709 eprintln!(" ⚠ Could not fetch balance for {}", address);
710 tracing::debug!(error = %e, address = %address, "Failed to fetch balance");
711 "Error".to_string()
712 }
713 };
714
715 let tokens = match client.get_token_balances(address).await {
717 Ok(token_bals) => token_bals
718 .into_iter()
719 .map(|tb| TokenSummary {
720 contract_address: tb.token.contract_address,
721 balance: tb.formatted_balance,
722 decimals: tb.token.decimals,
723 symbol: Some(tb.token.symbol),
724 })
725 .collect(),
726 Err(e) => {
727 eprintln!(" ⚠ Token balances unavailable");
728 tracing::debug!(error = %e, "Could not fetch token balances");
729 vec![]
730 }
731 };
732
733 (native_balance, tokens)
734}
735
736#[cfg(test)]
741mod tests {
742 use super::*;
743 use tempfile::TempDir;
744
745 fn create_test_address_book() -> AddressBook {
746 AddressBook {
747 addresses: vec![
748 WatchedAddress {
749 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
750 label: Some("Main Wallet".to_string()),
751 chain: "ethereum".to_string(),
752 tags: vec!["personal".to_string()],
753 added_at: 1700000000,
754 },
755 WatchedAddress {
756 address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
757 label: None,
758 chain: "polygon".to_string(),
759 tags: vec![],
760 added_at: 1700000001,
761 },
762 ],
763 }
764 }
765
766 #[test]
767 fn test_address_book_default() {
768 let address_book = AddressBook::default();
769 assert!(address_book.addresses.is_empty());
770 }
771
772 #[test]
773 fn test_address_book_add_address() {
774 let mut address_book = AddressBook::default();
775
776 let watched = WatchedAddress {
777 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
778 label: Some("Test".to_string()),
779 chain: "ethereum".to_string(),
780 tags: vec![],
781 added_at: 0,
782 };
783
784 let result = address_book.add_address(watched);
785 assert!(result.is_ok());
786 assert_eq!(address_book.addresses.len(), 1);
787 }
788
789 #[test]
790 fn test_address_book_add_duplicate_fails() {
791 let mut address_book = AddressBook::default();
792
793 let watched1 = WatchedAddress {
794 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
795 label: Some("First".to_string()),
796 chain: "ethereum".to_string(),
797 tags: vec![],
798 added_at: 0,
799 };
800
801 let watched2 = WatchedAddress {
802 address: "0x742d35CC6634C0532925A3b844Bc9e7595f1b3c2".to_string(), label: Some("Second".to_string()),
804 chain: "ethereum".to_string(),
805 tags: vec![],
806 added_at: 0,
807 };
808
809 address_book.add_address(watched1).unwrap();
810 let result = address_book.add_address(watched2);
811
812 assert!(result.is_err());
813 assert!(
814 result
815 .unwrap_err()
816 .to_string()
817 .contains("already in address book")
818 );
819 }
820
821 #[test]
822 fn test_address_book_remove_address() {
823 let mut address_book = create_test_address_book();
824 let original_len = address_book.addresses.len();
825
826 let removed = address_book
827 .remove_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2")
828 .unwrap();
829
830 assert!(removed);
831 assert_eq!(address_book.addresses.len(), original_len - 1);
832 }
833
834 #[test]
835 fn test_address_book_remove_nonexistent() {
836 let mut address_book = create_test_address_book();
837 let original_len = address_book.addresses.len();
838
839 let removed = address_book.remove_address("0xnonexistent").unwrap();
840
841 assert!(!removed);
842 assert_eq!(address_book.addresses.len(), original_len);
843 }
844
845 #[test]
846 fn test_address_book_find_address() {
847 let address_book = create_test_address_book();
848
849 let found = address_book.find_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
850 assert!(found.is_some());
851 assert_eq!(found.unwrap().label, Some("Main Wallet".to_string()));
852
853 let not_found = address_book.find_address("0xnonexistent");
854 assert!(not_found.is_none());
855 }
856
857 #[test]
858 fn test_address_book_find_address_case_insensitive() {
859 let address_book = create_test_address_book();
860
861 let found = address_book.find_address("0x742D35CC6634C0532925A3B844BC9E7595F1B3C2");
862 assert!(found.is_some());
863 }
864
865 #[test]
866 fn test_address_book_save_and_load() {
867 let temp_dir = TempDir::new().unwrap();
868 let data_dir = temp_dir.path().to_path_buf();
869
870 let address_book = create_test_address_book();
871 address_book.save(&data_dir).unwrap();
872
873 let loaded = AddressBook::load(&data_dir).unwrap();
874 assert_eq!(loaded.addresses.len(), address_book.addresses.len());
875 assert_eq!(
876 loaded.addresses[0].address,
877 address_book.addresses[0].address
878 );
879 }
880
881 #[test]
882 fn test_address_book_load_nonexistent_returns_default() {
883 let temp_dir = TempDir::new().unwrap();
884 let data_dir = temp_dir.path().to_path_buf();
885
886 let address_book = AddressBook::load(&data_dir).unwrap();
887 assert!(address_book.addresses.is_empty());
888 }
889
890 #[test]
891 fn test_watched_address_serialization() {
892 let watched = WatchedAddress {
893 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
894 label: Some("Test".to_string()),
895 chain: "ethereum".to_string(),
896 tags: vec!["tag1".to_string(), "tag2".to_string()],
897 added_at: 1700000000,
898 };
899
900 let json = serde_json::to_string(&watched).unwrap();
901 assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
902 assert!(json.contains("Test"));
903 assert!(json.contains("tag1"));
904
905 let deserialized: WatchedAddress = serde_json::from_str(&json).unwrap();
906 assert_eq!(deserialized.address, watched.address);
907 assert_eq!(deserialized.tags.len(), 2);
908 }
909
910 #[test]
911 fn test_address_book_summary_serialization() {
912 let summary = AddressBookSummary {
913 address_count: 2,
914 balances_by_chain: HashMap::new(),
915 total_usd: Some(10000.0),
916 addresses: vec![AddressSummary {
917 address: "0x123".to_string(),
918 label: Some("Test".to_string()),
919 chain: "ethereum".to_string(),
920 balance: "1.5".to_string(),
921 usd: Some(5000.0),
922 tokens: vec![],
923 }],
924 };
925
926 let json = serde_json::to_string(&summary).unwrap();
927 assert!(json.contains("10000"));
928 assert!(json.contains("0x123"));
929 }
930
931 #[test]
932 fn test_address_book_args_parsing() {
933 use clap::Parser;
934
935 #[derive(Parser)]
936 struct TestCli {
937 #[command(flatten)]
938 args: AddressBookArgs,
939 }
940
941 let cli = TestCli::try_parse_from(["test", "list"]).unwrap();
942 assert!(matches!(cli.args.command, AddressBookCommands::List));
943 }
944
945 #[test]
946 fn test_address_book_add_args_parsing() {
947 use clap::Parser;
948
949 #[derive(Parser)]
950 struct TestCli {
951 #[command(flatten)]
952 args: AddressBookArgs,
953 }
954
955 let cli = TestCli::try_parse_from([
956 "test",
957 "add",
958 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
959 "--label",
960 "My Wallet",
961 "--chain",
962 "polygon",
963 "--tags",
964 "personal,defi",
965 ])
966 .unwrap();
967
968 if let AddressBookCommands::Add(add_args) = cli.args.command {
969 assert_eq!(
970 add_args.address,
971 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
972 );
973 assert_eq!(add_args.label, Some("My Wallet".to_string()));
974 assert_eq!(add_args.chain, "polygon");
975 assert_eq!(add_args.tags, vec!["personal", "defi"]);
976 } else {
977 panic!("Expected Add command");
978 }
979 }
980
981 #[test]
982 fn test_chain_balance_serialization() {
983 let balance = ChainBalance {
984 native_balance: "10.5".to_string(),
985 symbol: "ETH".to_string(),
986 usd: Some(35000.0),
987 };
988
989 let json = serde_json::to_string(&balance).unwrap();
990 assert!(json.contains("10.5"));
991 assert!(json.contains("ETH"));
992 assert!(json.contains("35000"));
993 }
994
995 #[test]
1000 fn test_get_native_symbol_solana() {
1001 assert_eq!(native_symbol("solana"), "SOL");
1002 assert_eq!(native_symbol("sol"), "SOL");
1003 }
1004
1005 #[test]
1006 fn test_get_native_symbol_ethereum() {
1007 assert_eq!(native_symbol("ethereum"), "ETH");
1008 assert_eq!(native_symbol("eth"), "ETH");
1009 }
1010
1011 #[test]
1012 fn test_get_native_symbol_tron() {
1013 assert_eq!(native_symbol("tron"), "TRX");
1014 assert_eq!(native_symbol("trx"), "TRX");
1015 }
1016
1017 #[test]
1018 fn test_get_native_symbol_unknown() {
1019 assert_eq!(native_symbol("bitcoin"), "???");
1020 assert_eq!(native_symbol("unknown"), "???");
1021 }
1022
1023 use crate::chains::mocks::MockClientFactory;
1028
1029 fn mock_factory() -> MockClientFactory {
1030 MockClientFactory::new()
1031 }
1032
1033 #[tokio::test]
1034 async fn test_run_address_book_list_empty() {
1035 let tmp_dir = tempfile::tempdir().unwrap();
1036 let config = Config {
1037 address_book: crate::config::AddressBookConfig {
1038 data_dir: Some(tmp_dir.path().to_path_buf()),
1039 },
1040 ..Default::default()
1041 };
1042 let factory = mock_factory();
1043 let args = AddressBookArgs {
1044 command: AddressBookCommands::List,
1045 format: Some(OutputFormat::Table),
1046 };
1047 let result = super::run(args, &config, &factory).await;
1048 assert!(result.is_ok());
1049 }
1050
1051 #[tokio::test]
1052 async fn test_run_address_book_add_and_list() {
1053 let tmp_dir = tempfile::tempdir().unwrap();
1054 let config = Config {
1055 address_book: crate::config::AddressBookConfig {
1056 data_dir: Some(tmp_dir.path().to_path_buf()),
1057 },
1058 ..Default::default()
1059 };
1060 let factory = mock_factory();
1061
1062 let add_args = AddressBookArgs {
1064 command: AddressBookCommands::Add(AddArgs {
1065 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1066 label: Some("Test Wallet".to_string()),
1067 chain: "ethereum".to_string(),
1068 tags: vec!["test".to_string()],
1069 }),
1070 format: Some(OutputFormat::Table),
1071 };
1072 let result = super::run(add_args, &config, &factory).await;
1073 assert!(result.is_ok());
1074
1075 let list_args = AddressBookArgs {
1077 command: AddressBookCommands::List,
1078 format: Some(OutputFormat::Json),
1079 };
1080 let result = super::run(list_args, &config, &factory).await;
1081 assert!(result.is_ok());
1082 }
1083
1084 #[tokio::test]
1085 async fn test_run_address_book_summary_with_mock() {
1086 let tmp_dir = tempfile::tempdir().unwrap();
1087 let config = Config {
1088 address_book: crate::config::AddressBookConfig {
1089 data_dir: Some(tmp_dir.path().to_path_buf()),
1090 },
1091 ..Default::default()
1092 };
1093 let factory = mock_factory();
1094
1095 let add_args = AddressBookArgs {
1097 command: AddressBookCommands::Add(AddArgs {
1098 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1099 label: Some("Test".to_string()),
1100 chain: "ethereum".to_string(),
1101 tags: vec![],
1102 }),
1103 format: None,
1104 };
1105 super::run(add_args, &config, &factory).await.unwrap();
1106
1107 let summary_args = AddressBookArgs {
1109 command: AddressBookCommands::Summary(SummaryArgs {
1110 chain: None,
1111 tag: None,
1112 include_tokens: false,
1113 report: None,
1114 }),
1115 format: Some(OutputFormat::Json),
1116 };
1117 let result = super::run(summary_args, &config, &factory).await;
1118 assert!(result.is_ok());
1119 }
1120
1121 #[tokio::test]
1122 async fn test_run_address_book_remove() {
1123 let tmp_dir = tempfile::tempdir().unwrap();
1124 let config = Config {
1125 address_book: crate::config::AddressBookConfig {
1126 data_dir: Some(tmp_dir.path().to_path_buf()),
1127 },
1128 ..Default::default()
1129 };
1130 let factory = mock_factory();
1131
1132 let add_args = AddressBookArgs {
1134 command: AddressBookCommands::Add(AddArgs {
1135 address: "0xtest".to_string(),
1136 label: None,
1137 chain: "ethereum".to_string(),
1138 tags: vec![],
1139 }),
1140 format: None,
1141 };
1142 super::run(add_args, &config, &factory).await.unwrap();
1143
1144 let remove_args = AddressBookArgs {
1145 command: AddressBookCommands::Remove(RemoveArgs {
1146 address: "0xtest".to_string(),
1147 }),
1148 format: None,
1149 };
1150 let result = super::run(remove_args, &config, &factory).await;
1151 assert!(result.is_ok());
1152 }
1153
1154 #[tokio::test]
1155 async fn test_run_address_book_summary_csv() {
1156 let tmp_dir = tempfile::tempdir().unwrap();
1157 let config = Config {
1158 address_book: crate::config::AddressBookConfig {
1159 data_dir: Some(tmp_dir.path().to_path_buf()),
1160 },
1161 ..Default::default()
1162 };
1163 let factory = mock_factory();
1164
1165 let add_args = AddressBookArgs {
1167 command: AddressBookCommands::Add(AddArgs {
1168 address: "0xtest".to_string(),
1169 label: Some("TestAddr".to_string()),
1170 chain: "ethereum".to_string(),
1171 tags: vec!["defi".to_string()],
1172 }),
1173 format: None,
1174 };
1175 super::run(add_args, &config, &factory).await.unwrap();
1176
1177 let summary_args = AddressBookArgs {
1179 command: AddressBookCommands::Summary(SummaryArgs {
1180 chain: None,
1181 tag: None,
1182 include_tokens: false,
1183 report: None,
1184 }),
1185 format: Some(OutputFormat::Csv),
1186 };
1187 let result = super::run(summary_args, &config, &factory).await;
1188 assert!(result.is_ok());
1189 }
1190
1191 #[tokio::test]
1192 async fn test_run_address_book_summary_table() {
1193 let tmp_dir = tempfile::tempdir().unwrap();
1194 let config = Config {
1195 address_book: crate::config::AddressBookConfig {
1196 data_dir: Some(tmp_dir.path().to_path_buf()),
1197 },
1198 ..Default::default()
1199 };
1200 let factory = mock_factory();
1201
1202 let add_args = AddressBookArgs {
1204 command: AddressBookCommands::Add(AddArgs {
1205 address: "0xtest".to_string(),
1206 label: Some("TestAddr".to_string()),
1207 chain: "ethereum".to_string(),
1208 tags: vec![],
1209 }),
1210 format: None,
1211 };
1212 super::run(add_args, &config, &factory).await.unwrap();
1213
1214 let summary_args = AddressBookArgs {
1216 command: AddressBookCommands::Summary(SummaryArgs {
1217 chain: None,
1218 tag: None,
1219 include_tokens: true,
1220 report: None,
1221 }),
1222 format: Some(OutputFormat::Table),
1223 };
1224 let result = super::run(summary_args, &config, &factory).await;
1225 assert!(result.is_ok());
1226 }
1227
1228 #[tokio::test]
1229 async fn test_run_address_book_summary_with_chain_filter() {
1230 let tmp_dir = tempfile::tempdir().unwrap();
1231 let config = Config {
1232 address_book: crate::config::AddressBookConfig {
1233 data_dir: Some(tmp_dir.path().to_path_buf()),
1234 },
1235 ..Default::default()
1236 };
1237 let factory = mock_factory();
1238
1239 let add_eth = AddressBookArgs {
1241 command: AddressBookCommands::Add(AddArgs {
1242 address: "0xeth".to_string(),
1243 label: None,
1244 chain: "ethereum".to_string(),
1245 tags: vec![],
1246 }),
1247 format: None,
1248 };
1249 super::run(add_eth, &config, &factory).await.unwrap();
1250
1251 let add_poly = AddressBookArgs {
1252 command: AddressBookCommands::Add(AddArgs {
1253 address: "0xpoly".to_string(),
1254 label: None,
1255 chain: "polygon".to_string(),
1256 tags: vec![],
1257 }),
1258 format: None,
1259 };
1260 super::run(add_poly, &config, &factory).await.unwrap();
1261
1262 let summary_args = AddressBookArgs {
1264 command: AddressBookCommands::Summary(SummaryArgs {
1265 chain: Some("ethereum".to_string()),
1266 tag: None,
1267 include_tokens: false,
1268 report: None,
1269 }),
1270 format: Some(OutputFormat::Json),
1271 };
1272 let result = super::run(summary_args, &config, &factory).await;
1273 assert!(result.is_ok());
1274 }
1275
1276 #[tokio::test]
1277 async fn test_run_address_book_summary_with_tag_filter() {
1278 let tmp_dir = tempfile::tempdir().unwrap();
1279 let config = Config {
1280 address_book: crate::config::AddressBookConfig {
1281 data_dir: Some(tmp_dir.path().to_path_buf()),
1282 },
1283 ..Default::default()
1284 };
1285 let factory = mock_factory();
1286
1287 let add_args = AddressBookArgs {
1289 command: AddressBookCommands::Add(AddArgs {
1290 address: "0xdefi".to_string(),
1291 label: None,
1292 chain: "ethereum".to_string(),
1293 tags: vec!["defi".to_string()],
1294 }),
1295 format: None,
1296 };
1297 super::run(add_args, &config, &factory).await.unwrap();
1298
1299 let summary_args = AddressBookArgs {
1301 command: AddressBookCommands::Summary(SummaryArgs {
1302 chain: None,
1303 tag: Some("defi".to_string()),
1304 include_tokens: false,
1305 report: None,
1306 }),
1307 format: Some(OutputFormat::Json),
1308 };
1309 let result = super::run(summary_args, &config, &factory).await;
1310 assert!(result.is_ok());
1311 }
1312
1313 #[tokio::test]
1314 async fn test_run_address_book_summary_no_format() {
1315 let tmp_dir = tempfile::tempdir().unwrap();
1316 let config = Config {
1317 address_book: crate::config::AddressBookConfig {
1318 data_dir: Some(tmp_dir.path().to_path_buf()),
1319 },
1320 ..Default::default()
1321 };
1322 let factory = mock_factory();
1323
1324 let add_args = AddressBookArgs {
1325 command: AddressBookCommands::Add(AddArgs {
1326 address: "0xtest".to_string(),
1327 label: None,
1328 chain: "ethereum".to_string(),
1329 tags: vec![],
1330 }),
1331 format: None,
1332 };
1333 super::run(add_args, &config, &factory).await.unwrap();
1334
1335 let summary_args = AddressBookArgs {
1336 command: AddressBookCommands::Summary(SummaryArgs {
1337 chain: None,
1338 tag: None,
1339 include_tokens: false,
1340 report: None,
1341 }),
1342 format: None, };
1344 let result = super::run(summary_args, &config, &factory).await;
1345 assert!(result.is_ok());
1346 }
1347
1348 #[tokio::test]
1349 async fn test_run_address_book_summary_empty() {
1350 let tmp_dir = tempfile::tempdir().unwrap();
1351 let config = Config {
1352 address_book: crate::config::AddressBookConfig {
1353 data_dir: Some(tmp_dir.path().to_path_buf()),
1354 },
1355 ..Default::default()
1356 };
1357 let factory = mock_factory();
1358
1359 let summary_args = AddressBookArgs {
1361 command: AddressBookCommands::Summary(SummaryArgs {
1362 chain: None,
1363 tag: None,
1364 include_tokens: false,
1365 report: None,
1366 }),
1367 format: Some(OutputFormat::Table),
1368 };
1369 let result = super::run(summary_args, &config, &factory).await;
1370 assert!(result.is_ok());
1371 }
1372
1373 #[tokio::test]
1374 async fn test_run_address_book_add_with_tags() {
1375 let tmp_dir = tempfile::tempdir().unwrap();
1376 let config = Config {
1377 address_book: crate::config::AddressBookConfig {
1378 data_dir: Some(tmp_dir.path().to_path_buf()),
1379 },
1380 ..Default::default()
1381 };
1382 let factory = mock_factory();
1383
1384 let add_args = AddressBookArgs {
1385 command: AddressBookCommands::Add(AddArgs {
1386 address: "0xtagged".to_string(),
1387 label: Some("Tagged".to_string()),
1388 chain: "ethereum".to_string(),
1389 tags: vec!["defi".to_string(), "whale".to_string()],
1390 }),
1391 format: None,
1392 };
1393 let result = super::run(add_args, &config, &factory).await;
1394 assert!(result.is_ok());
1395 }
1396
1397 #[test]
1398 fn test_get_native_symbol_polygon() {
1399 assert_eq!(native_symbol("polygon"), "MATIC");
1400 }
1401
1402 #[test]
1403 fn test_get_native_symbol_bsc() {
1404 assert_eq!(native_symbol("bsc"), "BNB");
1405 }
1406
1407 #[test]
1408 fn test_get_native_symbol_evm_l2s() {
1409 assert_eq!(native_symbol("arbitrum"), "ETH");
1410 assert_eq!(native_symbol("optimism"), "ETH");
1411 assert_eq!(native_symbol("base"), "ETH");
1412 }
1413
1414 #[tokio::test]
1415 async fn test_run_address_book_list_csv_format() {
1416 let tmp_dir = tempfile::tempdir().unwrap();
1417 let config = Config {
1418 address_book: crate::config::AddressBookConfig {
1419 data_dir: Some(tmp_dir.path().to_path_buf()),
1420 },
1421 ..Default::default()
1422 };
1423 let factory = mock_factory();
1424
1425 let add_args = AddressBookArgs {
1427 command: AddressBookCommands::Add(AddArgs {
1428 address: "0xCSV_test".to_string(),
1429 label: Some("CsvAddr".to_string()),
1430 chain: "ethereum".to_string(),
1431 tags: vec!["test".to_string()],
1432 }),
1433 format: None,
1434 };
1435 super::run(add_args, &config, &factory).await.unwrap();
1436
1437 let list_args = AddressBookArgs {
1439 command: AddressBookCommands::List,
1440 format: Some(OutputFormat::Csv),
1441 };
1442 let result = super::run(list_args, &config, &factory).await;
1443 assert!(result.is_ok());
1444 }
1445
1446 #[tokio::test]
1447 async fn test_run_address_book_list_table_format() {
1448 let tmp_dir = tempfile::tempdir().unwrap();
1449 let config = Config {
1450 address_book: crate::config::AddressBookConfig {
1451 data_dir: Some(tmp_dir.path().to_path_buf()),
1452 },
1453 ..Default::default()
1454 };
1455 let factory = mock_factory();
1456
1457 let add_args = AddressBookArgs {
1459 command: AddressBookCommands::Add(AddArgs {
1460 address: "0xTable_test1".to_string(),
1461 label: Some("LabeledAddr".to_string()),
1462 chain: "ethereum".to_string(),
1463 tags: vec!["personal".to_string(), "defi".to_string()],
1464 }),
1465 format: None,
1466 };
1467 super::run(add_args, &config, &factory).await.unwrap();
1468
1469 let add_args2 = AddressBookArgs {
1470 command: AddressBookCommands::Add(AddArgs {
1471 address: "0xTable_test2".to_string(),
1472 label: None,
1473 chain: "polygon".to_string(),
1474 tags: vec![],
1475 }),
1476 format: None,
1477 };
1478 super::run(add_args2, &config, &factory).await.unwrap();
1479
1480 let list_args = AddressBookArgs {
1482 command: AddressBookCommands::List,
1483 format: Some(OutputFormat::Table),
1484 };
1485 let result = super::run(list_args, &config, &factory).await;
1486 assert!(result.is_ok());
1487 }
1488
1489 #[tokio::test]
1490 async fn test_run_address_book_summary_table_with_tokens() {
1491 let tmp_dir = tempfile::tempdir().unwrap();
1492 let config = Config {
1493 address_book: crate::config::AddressBookConfig {
1494 data_dir: Some(tmp_dir.path().to_path_buf()),
1495 },
1496 ..Default::default()
1497 };
1498 let factory = mock_factory();
1499
1500 let add_args = AddressBookArgs {
1502 command: AddressBookCommands::Add(AddArgs {
1503 address: "0xTokenTest".to_string(),
1504 label: Some("TokenAddr".to_string()),
1505 chain: "ethereum".to_string(),
1506 tags: vec![],
1507 }),
1508 format: None,
1509 };
1510 super::run(add_args, &config, &factory).await.unwrap();
1511
1512 let summary_args = AddressBookArgs {
1514 command: AddressBookCommands::Summary(SummaryArgs {
1515 chain: None,
1516 tag: None,
1517 include_tokens: true,
1518 report: None,
1519 }),
1520 format: Some(OutputFormat::Table),
1521 };
1522 let result = super::run(summary_args, &config, &factory).await;
1523 assert!(result.is_ok());
1524 }
1525
1526 #[tokio::test]
1527 async fn test_run_address_book_summary_multiple_chains() {
1528 let tmp_dir = tempfile::tempdir().unwrap();
1529 let config = Config {
1530 address_book: crate::config::AddressBookConfig {
1531 data_dir: Some(tmp_dir.path().to_path_buf()),
1532 },
1533 ..Default::default()
1534 };
1535 let factory = mock_factory();
1536
1537 let add1 = AddressBookArgs {
1539 command: AddressBookCommands::Add(AddArgs {
1540 address: "0xMulti1".to_string(),
1541 label: None,
1542 chain: "ethereum".to_string(),
1543 tags: vec![],
1544 }),
1545 format: None,
1546 };
1547 super::run(add1, &config, &factory).await.unwrap();
1548
1549 let add2 = AddressBookArgs {
1550 command: AddressBookCommands::Add(AddArgs {
1551 address: "0xMulti2".to_string(),
1552 label: None,
1553 chain: "ethereum".to_string(),
1554 tags: vec![],
1555 }),
1556 format: None,
1557 };
1558 super::run(add2, &config, &factory).await.unwrap();
1559
1560 let summary_args = AddressBookArgs {
1562 command: AddressBookCommands::Summary(SummaryArgs {
1563 chain: None,
1564 tag: None,
1565 include_tokens: false,
1566 report: None,
1567 }),
1568 format: Some(OutputFormat::Table),
1569 };
1570 let result = super::run(summary_args, &config, &factory).await;
1571 assert!(result.is_ok());
1572 }
1573
1574 #[tokio::test]
1575 async fn test_run_address_book_list_no_format() {
1576 let tmp_dir = tempfile::tempdir().unwrap();
1577 let config = Config {
1578 address_book: crate::config::AddressBookConfig {
1579 data_dir: Some(tmp_dir.path().to_path_buf()),
1580 },
1581 ..Default::default()
1582 };
1583 let factory = mock_factory();
1584
1585 let add_args = AddressBookArgs {
1587 command: AddressBookCommands::Add(AddArgs {
1588 address: "0xNoFmt".to_string(),
1589 label: Some("Test".to_string()),
1590 chain: "ethereum".to_string(),
1591 tags: vec![],
1592 }),
1593 format: None,
1594 };
1595 super::run(add_args, &config, &factory).await.unwrap();
1596
1597 let list_args = AddressBookArgs {
1599 command: AddressBookCommands::List,
1600 format: None,
1601 };
1602 let result = super::run(list_args, &config, &factory).await;
1603 assert!(result.is_ok());
1604 }
1605
1606 #[test]
1607 fn test_address_book_new() {
1608 let p = AddressBook::default();
1609 assert!(p.addresses.is_empty());
1610 }
1611
1612 #[test]
1613 fn test_address_book_load_missing_dir() {
1614 let temp = tempfile::tempdir().unwrap();
1615 let p = AddressBook::load(temp.path());
1616 assert!(p.is_ok());
1617 assert!(p.unwrap().addresses.is_empty());
1618 }
1619
1620 #[test]
1621 fn test_address_book_add_and_save_roundtrip() {
1622 let temp = tempfile::tempdir().unwrap();
1623 let mut p = AddressBook::default();
1624 let addr = WatchedAddress {
1625 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1626 label: Some("Test".to_string()),
1627 chain: "ethereum".to_string(),
1628 tags: vec!["tag1".to_string()],
1629 added_at: 1234567890,
1630 };
1631 p.add_address(addr).unwrap();
1632 assert_eq!(p.addresses.len(), 1);
1633
1634 let data_dir = temp.path().to_path_buf();
1635 p.save(&data_dir).unwrap();
1636 let loaded = AddressBook::load(temp.path()).unwrap();
1637 assert_eq!(loaded.addresses.len(), 1);
1638 assert_eq!(
1639 loaded.addresses[0].address,
1640 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1641 );
1642 assert_eq!(loaded.addresses[0].label, Some("Test".to_string()));
1643 }
1644
1645 #[test]
1646 fn test_address_book_add_duplicate() {
1647 let mut p = AddressBook::default();
1648 let addr1 = WatchedAddress {
1649 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1650 label: None,
1651 chain: "ethereum".to_string(),
1652 tags: vec![],
1653 added_at: 0,
1654 };
1655 let addr2 = WatchedAddress {
1656 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1657 label: None,
1658 chain: "ethereum".to_string(),
1659 tags: vec![],
1660 added_at: 0,
1661 };
1662 p.add_address(addr1).unwrap();
1663 let result = p.add_address(addr2);
1664 assert!(result.is_err());
1666 assert!(
1667 result
1668 .unwrap_err()
1669 .to_string()
1670 .contains("already in address book")
1671 );
1672 }
1673
1674 #[test]
1675 fn test_watched_address_debug() {
1676 let addr = WatchedAddress {
1677 address: "0xtest".to_string(),
1678 label: Some("My Wallet".to_string()),
1679 chain: "ethereum".to_string(),
1680 tags: vec!["defi".to_string(), "staking".to_string()],
1681 added_at: 1700000000,
1682 };
1683 let debug = format!("{:?}", addr);
1684 assert!(debug.contains("WatchedAddress"));
1685 assert!(debug.contains("0xtest"));
1686 }
1687
1688 #[test]
1693 fn test_address_book_summary_to_markdown_basic() {
1694 let mut balances_by_chain = HashMap::new();
1695 balances_by_chain.insert(
1696 "ethereum".to_string(),
1697 ChainBalance {
1698 native_balance: "1.5".to_string(),
1699 symbol: "ETH".to_string(),
1700 usd: None,
1701 },
1702 );
1703
1704 let summary = AddressBookSummary {
1705 address_count: 2,
1706 balances_by_chain,
1707 total_usd: None,
1708 addresses: vec![
1709 AddressSummary {
1710 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1711 label: Some("Main Wallet".to_string()),
1712 chain: "ethereum".to_string(),
1713 balance: "1.5".to_string(),
1714 usd: None,
1715 tokens: vec![],
1716 },
1717 AddressSummary {
1718 address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
1719 label: None,
1720 chain: "polygon".to_string(),
1721 balance: "100.0".to_string(),
1722 usd: None,
1723 tokens: vec![],
1724 },
1725 ],
1726 };
1727
1728 let md = address_book_summary_to_markdown(&summary);
1729
1730 assert!(md.contains("# Address Book Report"));
1732 assert!(md.contains("**Addresses:** 2"));
1733 assert!(md.contains("Allocation by Chain"));
1734 assert!(md.contains("## Addresses"));
1735
1736 assert!(md.contains("ethereum"));
1738 assert!(md.contains("1.5"));
1739 assert!(md.contains("ETH"));
1740
1741 assert!(md.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
1743 assert!(md.contains("Main Wallet"));
1744 assert!(md.contains("0xABCdef1234567890abcdef1234567890ABCDEF12"));
1745 assert!(md.contains("polygon"));
1746 assert!(md.contains("100.0"));
1747
1748 assert!(md.contains("Report generated by Scope"));
1750 }
1751
1752 #[test]
1753 fn test_address_book_summary_to_markdown_with_usd() {
1754 let mut balances_by_chain = HashMap::new();
1755 balances_by_chain.insert(
1756 "ethereum".to_string(),
1757 ChainBalance {
1758 native_balance: "2.0".to_string(),
1759 symbol: "ETH".to_string(),
1760 usd: Some(3000.0),
1761 },
1762 );
1763
1764 let summary = AddressBookSummary {
1765 address_count: 2,
1766 balances_by_chain,
1767 total_usd: Some(5000.0),
1768 addresses: vec![
1769 AddressSummary {
1770 address: "0x1234567890123456789012345678901234567890".to_string(),
1771 label: Some("Wallet 1".to_string()),
1772 chain: "ethereum".to_string(),
1773 balance: "2.0".to_string(),
1774 usd: Some(3000.0),
1775 tokens: vec![],
1776 },
1777 AddressSummary {
1778 address: "0x0987654321098765432109876543210987654321".to_string(),
1779 label: Some("Wallet 2".to_string()),
1780 chain: "ethereum".to_string(),
1781 balance: "1.0".to_string(),
1782 usd: Some(2000.0),
1783 tokens: vec![],
1784 },
1785 ],
1786 };
1787
1788 let md = address_book_summary_to_markdown(&summary);
1789
1790 assert!(md.contains("**Total Value (USD):** $5000.00"));
1792
1793 assert!(md.contains("$3000.00"));
1795
1796 assert!(md.contains("$3000.00"));
1798 assert!(md.contains("$2000.00"));
1799 }
1800
1801 #[test]
1802 fn test_address_book_summary_to_markdown_with_tokens() {
1803 let mut balances_by_chain = HashMap::new();
1804 balances_by_chain.insert(
1805 "ethereum".to_string(),
1806 ChainBalance {
1807 native_balance: "1.0".to_string(),
1808 symbol: "ETH".to_string(),
1809 usd: None,
1810 },
1811 );
1812
1813 let tokens = vec![
1815 TokenSummary {
1816 contract_address: "0xToken1".to_string(),
1817 balance: "100.0".to_string(),
1818 decimals: 18,
1819 symbol: Some("USDC".to_string()),
1820 },
1821 TokenSummary {
1822 contract_address: "0xToken2".to_string(),
1823 balance: "50.0".to_string(),
1824 decimals: 18,
1825 symbol: Some("DAI".to_string()),
1826 },
1827 TokenSummary {
1828 contract_address: "0xToken3".to_string(),
1829 balance: "25.0".to_string(),
1830 decimals: 18,
1831 symbol: Some("WBTC".to_string()),
1832 },
1833 TokenSummary {
1834 contract_address: "0xToken4".to_string(),
1835 balance: "10.0".to_string(),
1836 decimals: 18,
1837 symbol: Some("UNI".to_string()),
1838 },
1839 TokenSummary {
1840 contract_address: "0xToken5".to_string(),
1841 balance: "5.0".to_string(),
1842 decimals: 18,
1843 symbol: None, },
1845 ];
1846
1847 let summary = AddressBookSummary {
1848 address_count: 1,
1849 balances_by_chain,
1850 total_usd: None,
1851 addresses: vec![AddressSummary {
1852 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1853 label: Some("Token Wallet".to_string()),
1854 chain: "ethereum".to_string(),
1855 balance: "1.0".to_string(),
1856 usd: None,
1857 tokens,
1858 }],
1859 };
1860
1861 let md = address_book_summary_to_markdown(&summary);
1862
1863 assert!(md.contains("USDC"));
1865 assert!(md.contains("DAI"));
1866 assert!(md.contains("WBTC"));
1867
1868 assert!(md.contains("+2"));
1870
1871 }
1876
1877 #[test]
1878 fn test_address_book_summary_to_markdown_empty() {
1879 let summary = AddressBookSummary {
1880 address_count: 0,
1881 balances_by_chain: HashMap::new(),
1882 total_usd: None,
1883 addresses: vec![],
1884 };
1885
1886 let md = address_book_summary_to_markdown(&summary);
1887
1888 assert!(md.contains("# Address Book Report"));
1890 assert!(md.contains("**Addresses:** 0"));
1891
1892 assert!(md.contains("Allocation by Chain"));
1894
1895 assert!(md.contains("## Addresses"));
1897
1898 assert!(md.contains("Report generated by Scope"));
1900 }
1901
1902 #[test]
1907 fn test_find_by_label_exact_match() {
1908 let address_book = create_test_address_book();
1909 let found = address_book.find_by_label("Main Wallet");
1910 assert!(found.is_some());
1911 assert_eq!(
1912 found.unwrap().address,
1913 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1914 );
1915 }
1916
1917 #[test]
1918 fn test_find_by_label_case_insensitive() {
1919 let address_book = create_test_address_book();
1920 let found = address_book.find_by_label("main wallet");
1921 assert!(found.is_some());
1922 assert_eq!(
1923 found.unwrap().address,
1924 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1925 );
1926 }
1927
1928 #[test]
1929 fn test_find_by_label_with_whitespace() {
1930 let address_book = create_test_address_book();
1931 let found = address_book.find_by_label(" Main Wallet ");
1932 assert!(found.is_some());
1933 }
1934
1935 #[test]
1936 fn test_find_by_label_not_found() {
1937 let address_book = create_test_address_book();
1938 let found = address_book.find_by_label("nonexistent");
1939 assert!(found.is_none());
1940 }
1941
1942 #[test]
1943 fn test_find_by_label_no_label_entries() {
1944 let address_book = create_test_address_book();
1945 let found = address_book.find_by_label("");
1947 assert!(found.is_none());
1948 }
1949
1950 #[test]
1951 fn test_find_by_label_empty_address_book() {
1952 let address_book = AddressBook::default();
1953 let found = address_book.find_by_label("anything");
1954 assert!(found.is_none());
1955 }
1956
1957 #[test]
1962 fn test_resolve_address_book_input_by_label() {
1963 let tmp_dir = TempDir::new().unwrap();
1964 let mut address_book = AddressBook::default();
1965 address_book
1966 .add_address(WatchedAddress {
1967 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1968 label: Some("hot-wallet".to_string()),
1969 chain: "ethereum".to_string(),
1970 tags: vec![],
1971 added_at: 0,
1972 })
1973 .unwrap();
1974 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
1975
1976 let config = Config {
1977 address_book: crate::config::AddressBookConfig {
1978 data_dir: Some(tmp_dir.path().to_path_buf()),
1979 },
1980 ..Default::default()
1981 };
1982
1983 let result = resolve_address_book_input("@hot-wallet", &config).unwrap();
1984 assert!(result.is_some());
1985 let (addr, chain) = result.unwrap();
1986 assert_eq!(addr, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
1987 assert_eq!(chain, "ethereum");
1988 }
1989
1990 #[test]
1991 fn test_resolve_address_book_input_by_address() {
1992 let tmp_dir = TempDir::new().unwrap();
1993 let mut address_book = AddressBook::default();
1994 address_book
1995 .add_address(WatchedAddress {
1996 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1997 label: Some("test".to_string()),
1998 chain: "polygon".to_string(),
1999 tags: vec![],
2000 added_at: 0,
2001 })
2002 .unwrap();
2003 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
2004
2005 let config = Config {
2006 address_book: crate::config::AddressBookConfig {
2007 data_dir: Some(tmp_dir.path().to_path_buf()),
2008 },
2009 ..Default::default()
2010 };
2011
2012 let result =
2014 resolve_address_book_input("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", &config)
2015 .unwrap();
2016 assert!(result.is_some());
2017 let (_addr, chain) = result.unwrap();
2018 assert_eq!(chain, "polygon");
2019 }
2020
2021 #[test]
2022 fn test_resolve_address_book_input_not_found() {
2023 let tmp_dir = TempDir::new().unwrap();
2024 let config = Config {
2025 address_book: crate::config::AddressBookConfig {
2026 data_dir: Some(tmp_dir.path().to_path_buf()),
2027 },
2028 ..Default::default()
2029 };
2030
2031 let result = resolve_address_book_input("@unknown-label", &config);
2032 assert!(result.is_err());
2033 }
2034
2035 #[test]
2036 fn test_resolve_address_book_input_empty_address_book() {
2037 let tmp_dir = TempDir::new().unwrap();
2038 let config = Config {
2039 address_book: crate::config::AddressBookConfig {
2040 data_dir: Some(tmp_dir.path().to_path_buf()),
2041 },
2042 ..Default::default()
2043 };
2044
2045 let result = resolve_address_book_input("@anything", &config);
2046 assert!(result.is_err());
2047 }
2048
2049 #[test]
2050 fn test_resolve_address_book_input_case_insensitive_label() {
2051 let tmp_dir = TempDir::new().unwrap();
2052 let mut address_book = AddressBook::default();
2053 address_book
2054 .add_address(WatchedAddress {
2055 address: "0xABCDEF1234567890abcdef1234567890ABCDEF12".to_string(),
2056 label: Some("My DeFi Wallet".to_string()),
2057 chain: "arbitrum".to_string(),
2058 tags: vec![],
2059 added_at: 0,
2060 })
2061 .unwrap();
2062 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
2063
2064 let config = Config {
2065 address_book: crate::config::AddressBookConfig {
2066 data_dir: Some(tmp_dir.path().to_path_buf()),
2067 },
2068 ..Default::default()
2069 };
2070
2071 let result = resolve_address_book_input("@my defi wallet", &config).unwrap();
2072 assert!(result.is_some());
2073 let (addr, chain) = result.unwrap();
2074 assert_eq!(addr, "0xABCDEF1234567890abcdef1234567890ABCDEF12");
2075 assert_eq!(chain, "arbitrum");
2076 }
2077}