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)]
60#[command(after_help = "\x1b[1mExamples:\x1b[0m
61 scope address-book add 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --label \"Main Wallet\"
62 scope address-book add 0x742d... --chain ethereum --tags hot,trading
63 scope ab add DRpbCBMx...TDt1v --chain solana --label \"SOL Vault\"")]
64pub struct AddArgs {
65 #[arg(value_name = "ADDRESS")]
67 pub address: String,
68
69 #[arg(short, long)]
71 pub label: Option<String>,
72
73 #[arg(short, long, default_value = "ethereum")]
75 pub chain: String,
76
77 #[arg(short, long, value_delimiter = ',')]
79 pub tags: Vec<String>,
80}
81
82#[derive(Debug, Clone, Args)]
84#[command(after_help = "\x1b[1mExamples:\x1b[0m
85 scope address-book remove 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
86 scope ab remove DRpbCBMx...TDt1v")]
87pub struct RemoveArgs {
88 #[arg(value_name = "ADDRESS")]
90 pub address: String,
91}
92
93#[derive(Debug, Clone, Args)]
95#[command(after_help = "\x1b[1mExamples:\x1b[0m
96 scope address-book summary
97 scope address-book summary --chain ethereum --include-tokens
98 scope ab summary --tag trading --report portfolio.md")]
99pub struct SummaryArgs {
100 #[arg(short, long)]
102 pub chain: Option<String>,
103
104 #[arg(short, long)]
106 pub tag: Option<String>,
107
108 #[arg(long)]
110 pub include_tokens: bool,
111
112 #[arg(long, value_name = "PATH")]
114 pub report: Option<std::path::PathBuf>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct WatchedAddress {
120 pub address: String,
122
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub label: Option<String>,
126
127 pub chain: String,
129
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
132 pub tags: Vec<String>,
133
134 pub added_at: u64,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, Default)]
140pub struct AddressBook {
141 pub addresses: Vec<WatchedAddress>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct AddressBookSummary {
148 pub address_count: usize,
150
151 pub balances_by_chain: HashMap<String, ChainBalance>,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub total_usd: Option<f64>,
157
158 pub addresses: Vec<AddressSummary>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ChainBalance {
165 pub native_balance: String,
167
168 pub symbol: String,
170
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub usd: Option<f64>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct AddressSummary {
179 pub address: String,
181
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub label: Option<String>,
185
186 pub chain: String,
188
189 pub balance: String,
191
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub usd: Option<f64>,
195
196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub tokens: Vec<TokenSummary>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct TokenSummary {
204 pub contract_address: String,
206 pub balance: String,
208 pub decimals: u8,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub symbol: Option<String>,
213}
214
215impl AddressBook {
216 pub fn load(data_dir: &std::path::Path) -> Result<Self> {
218 let path = data_dir.join("address_book.yaml");
219
220 if !path.exists() {
221 return Ok(Self::default());
222 }
223
224 let contents = std::fs::read_to_string(&path)?;
225 let address_book: AddressBook = serde_yaml::from_str(&contents)
226 .map_err(|e| ScopeError::Config(crate::error::ConfigError::Parse { source: e }))?;
227
228 Ok(address_book)
229 }
230
231 pub fn save(&self, data_dir: &PathBuf) -> Result<()> {
233 std::fs::create_dir_all(data_dir)?;
234
235 let path = data_dir.join("address_book.yaml");
236 let contents = serde_yaml::to_string(self)
237 .map_err(|e| ScopeError::Export(format!("Failed to serialize address book: {}", e)))?;
238
239 std::fs::write(&path, contents)?;
240 Ok(())
241 }
242
243 pub fn add_address(&mut self, watched: WatchedAddress) -> Result<()> {
245 if self
247 .addresses
248 .iter()
249 .any(|a| a.address.to_lowercase() == watched.address.to_lowercase())
250 {
251 return Err(ScopeError::Chain(format!(
252 "Address already in address book: {}",
253 watched.address
254 )));
255 }
256
257 self.addresses.push(watched);
258 Ok(())
259 }
260
261 pub fn remove_address(&mut self, address: &str) -> Result<bool> {
263 let original_len = self.addresses.len();
264 self.addresses
265 .retain(|a| a.address.to_lowercase() != address.to_lowercase());
266
267 Ok(self.addresses.len() < original_len)
268 }
269
270 pub fn find_address(&self, address: &str) -> Option<&WatchedAddress> {
272 self.addresses
273 .iter()
274 .find(|a| a.address.to_lowercase() == address.to_lowercase())
275 }
276
277 pub fn find_by_label(&self, label: &str) -> Option<&WatchedAddress> {
282 let needle = label.trim().to_lowercase();
283 self.addresses.iter().find(|a| {
284 a.label
285 .as_ref()
286 .is_some_and(|l| l.trim().to_lowercase() == needle)
287 })
288 }
289}
290
291pub fn resolve_address_book_input(
304 input: &str,
305 config: &Config,
306) -> crate::error::Result<Option<(String, String)>> {
307 let data_dir = config.data_dir();
308 let address_book = match AddressBook::load(&data_dir) {
309 Ok(ab) => ab,
310 Err(_) => return Ok(None),
311 };
312
313 if let Some(label) = input.strip_prefix('@') {
315 if let Some(watched) = address_book.find_by_label(label) {
316 let label_display = watched.label.as_deref().unwrap_or(label);
317 eprintln!(
318 " Using '{}' → {} ({})",
319 label_display, watched.address, watched.chain
320 );
321 return Ok(Some((watched.address.clone(), watched.chain.clone())));
322 }
323 let available: Vec<String> = address_book
325 .addresses
326 .iter()
327 .filter_map(|a| a.label.clone())
328 .collect();
329 let suggestion = if available.is_empty() {
330 "Your address book is empty. Add entries with `scope address-book add`.".to_string()
331 } else {
332 format!(
333 "Available labels: {}",
334 available
335 .iter()
336 .map(|l| format!("@{}", l))
337 .collect::<Vec<_>>()
338 .join(", ")
339 )
340 };
341 return Err(crate::error::ScopeError::NotFound(format!(
342 "No address book entry matching '@{}'.\n {}",
343 label, suggestion
344 )));
345 }
346
347 if let Some(watched) = address_book.find_address(input) {
349 if let Some(ref label) = watched.label {
350 tracing::debug!(
351 "Address book match by address for '{}' ({})",
352 label,
353 watched.chain
354 );
355 }
356 return Ok(Some((watched.address.clone(), watched.chain.clone())));
357 }
358
359 Ok(None)
360}
361
362pub async fn run(
364 args: AddressBookArgs,
365 config: &Config,
366 clients: &dyn ChainClientFactory,
367) -> Result<()> {
368 let data_dir = config.data_dir();
369 let format = args.format.unwrap_or(config.output.format);
370
371 match args.command {
372 AddressBookCommands::Add(add_args) => run_add(add_args, &data_dir).await,
373 AddressBookCommands::Remove(remove_args) => run_remove(remove_args, &data_dir).await,
374 AddressBookCommands::List => run_list(&data_dir, format).await,
375 AddressBookCommands::Summary(summary_args) => {
376 run_summary(summary_args, &data_dir, format, clients).await
377 }
378 }
379}
380
381async fn run_add(args: AddArgs, data_dir: &PathBuf) -> Result<()> {
382 tracing::info!(address = %args.address, "Adding address to address book");
383
384 let mut address_book = AddressBook::load(data_dir)?;
385
386 let watched = WatchedAddress {
387 address: args.address.clone(),
388 label: args.label.clone(),
389 chain: args.chain.clone(),
390 tags: args.tags.clone(),
391 added_at: std::time::SystemTime::now()
392 .duration_since(std::time::UNIX_EPOCH)
393 .unwrap_or_default()
394 .as_secs(),
395 };
396
397 address_book.add_address(watched)?;
398 address_book.save(data_dir)?;
399
400 println!(
401 "Added {} to address book{}",
402 args.address,
403 args.label
404 .map(|l| format!(" as '{}'", l))
405 .unwrap_or_default()
406 );
407
408 Ok(())
409}
410
411async fn run_remove(args: RemoveArgs, data_dir: &PathBuf) -> Result<()> {
412 tracing::info!(address = %args.address, "Removing address from address book");
413
414 let mut address_book = AddressBook::load(data_dir)?;
415 let removed = address_book.remove_address(&args.address)?;
416
417 if removed {
418 address_book.save(data_dir)?;
419 println!("Removed {} from address book", args.address);
420 } else {
421 println!("Address not found in address book: {}", args.address);
422 }
423
424 Ok(())
425}
426
427async fn run_list(data_dir: &std::path::Path, format: OutputFormat) -> Result<()> {
428 let address_book = AddressBook::load(data_dir)?;
429
430 if address_book.addresses.is_empty() {
431 println!("Address book is empty. Add addresses with 'scope address-book add <address>'");
432 return Ok(());
433 }
434
435 match format {
436 OutputFormat::Json => {
437 let json = serde_json::to_string_pretty(&address_book.addresses)?;
438 println!("{}", json);
439 }
440 OutputFormat::Csv => {
441 println!("address,label,chain,tags");
442 for addr in &address_book.addresses {
443 println!(
444 "{},{},{},{}",
445 addr.address,
446 addr.label.as_deref().unwrap_or(""),
447 addr.chain,
448 addr.tags.join(";")
449 );
450 }
451 }
452 OutputFormat::Table => {
453 println!("Address Book");
454 println!("===================");
455 for addr in &address_book.addresses {
456 println!(
457 " {} ({}) - {}{}",
458 addr.address,
459 addr.chain,
460 addr.label.as_deref().unwrap_or("No label"),
461 if addr.tags.is_empty() {
462 String::new()
463 } else {
464 format!(" [{}]", addr.tags.join(", "))
465 }
466 );
467 }
468 println!("\nTotal: {} addresses", address_book.addresses.len());
469 }
470 OutputFormat::Markdown => {
471 let mut md = "# Address Book\n\n".to_string();
472 md.push_str("| Address | Chain | Label | Tags |\n|---------|-------|-------|------|\n");
473 for addr in &address_book.addresses {
474 let tags = if addr.tags.is_empty() {
475 "-".to_string()
476 } else {
477 addr.tags.join(", ")
478 };
479 md.push_str(&format!(
480 "| `{}` | {} | {} | {} |\n",
481 addr.address,
482 addr.chain,
483 addr.label.as_deref().unwrap_or("-"),
484 tags
485 ));
486 }
487 md.push_str(&format!(
488 "\n**Total:** {} addresses\n",
489 address_book.addresses.len()
490 ));
491 println!("{}", md);
492 }
493 }
494
495 Ok(())
496}
497
498async fn run_summary(
499 args: SummaryArgs,
500 data_dir: &std::path::Path,
501 format: OutputFormat,
502 clients: &dyn ChainClientFactory,
503) -> Result<()> {
504 let address_book = AddressBook::load(data_dir)?;
505
506 if address_book.addresses.is_empty() {
507 println!("Address book is empty. Add addresses with 'scope address-book add <address>'");
508 return Ok(());
509 }
510
511 let filtered: Vec<_> = address_book
513 .addresses
514 .iter()
515 .filter(|a| args.chain.as_ref().is_none_or(|c| &a.chain == c))
516 .filter(|a| args.tag.as_ref().is_none_or(|t| a.tags.contains(t)))
517 .collect();
518
519 let mut address_summaries = Vec::new();
521 let mut balances_by_chain: HashMap<String, ChainBalance> = HashMap::new();
522
523 for watched in &filtered {
524 let (balance, tokens) = fetch_address_balance(
525 &watched.address,
526 &watched.chain,
527 clients,
528 args.include_tokens,
529 )
530 .await;
531
532 if let Some(chain_bal) = balances_by_chain.get_mut(&watched.chain) {
534 let _ = chain_bal;
537 } else {
538 balances_by_chain.insert(
539 watched.chain.clone(),
540 ChainBalance {
541 native_balance: balance.clone(),
542 symbol: native_symbol(&watched.chain).to_string(),
543 usd: None,
544 },
545 );
546 }
547
548 address_summaries.push(AddressSummary {
549 address: watched.address.clone(),
550 label: watched.label.clone(),
551 chain: watched.chain.clone(),
552 balance,
553 usd: None,
554 tokens,
555 });
556 }
557
558 let summary = AddressBookSummary {
559 address_count: filtered.len(),
560 balances_by_chain,
561 total_usd: None,
562 addresses: address_summaries,
563 };
564
565 match format {
566 OutputFormat::Json => {
567 let json = serde_json::to_string_pretty(&summary)?;
568 println!("{}", json);
569 }
570 OutputFormat::Csv => {
571 println!("address,label,chain,balance,usd");
572 for addr in &summary.addresses {
573 println!(
574 "{},{},{},{},{}",
575 addr.address,
576 addr.label.as_deref().unwrap_or(""),
577 addr.chain,
578 addr.balance,
579 addr.usd.map_or(String::new(), |u| format!("{:.2}", u))
580 );
581 }
582 }
583 OutputFormat::Table => {
584 println!("Address Book Summary");
585 println!("=================");
586 println!("Addresses: {}", summary.address_count);
587 println!();
588
589 for addr in &summary.addresses {
590 println!(
591 " {} ({}) - {} {}",
592 addr.label.as_deref().unwrap_or(&addr.address),
593 addr.chain,
594 addr.balance,
595 addr.usd.map_or(String::new(), |u| format!("(${:.2})", u))
596 );
597
598 for token in &addr.tokens {
600 let addr_short = if token.contract_address.len() >= 8 {
601 &token.contract_address[..8]
602 } else {
603 &token.contract_address
604 };
605 let symbol = token.symbol.as_deref().unwrap_or(addr_short);
606 println!(" └─ {} {}", token.balance, symbol);
607 }
608 }
609
610 if let Some(total) = summary.total_usd {
611 println!();
612 println!("Total Value: ${:.2}", total);
613 }
614 }
615 OutputFormat::Markdown => {
616 let md = address_book_summary_to_markdown(&summary);
617 println!("{}", md);
618 }
619 }
620
621 if let Some(ref report_path) = args.report {
623 let md = address_book_summary_to_markdown(&summary);
624 std::fs::write(report_path, md)?;
625 println!("\nReport saved to: {}", report_path.display());
626 }
627
628 Ok(())
629}
630
631fn address_book_summary_to_markdown(summary: &AddressBookSummary) -> String {
633 let mut md = format!(
634 "# Address Book Report\n\n\
635 **Generated:** {} \n\
636 **Addresses:** {} \n\n",
637 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
638 summary.address_count
639 );
640
641 if let Some(total) = summary.total_usd {
642 md.push_str(&format!("**Total Value (USD):** ${:.2} \n\n", total));
643 }
644
645 md.push_str("## Allocation by Chain\n\n");
646 md.push_str(
647 "| Chain | Native Balance | Symbol | USD |\n|-------|----------------|--------|-----|\n",
648 );
649 for (chain, bal) in &summary.balances_by_chain {
650 let usd = bal
651 .usd
652 .map(|u| format!("${:.2}", u))
653 .unwrap_or_else(|| "-".to_string());
654 md.push_str(&format!(
655 "| {} | {} | {} | {} |\n",
656 chain, bal.native_balance, bal.symbol, usd
657 ));
658 }
659
660 md.push_str("\n## Addresses\n\n");
661 md.push_str("| Address | Label | Chain | Balance | USD | Tokens |\n");
662 md.push_str("|---------|-------|-------|---------|-----|--------|\n");
663 for addr in &summary.addresses {
664 let label = addr.label.as_deref().unwrap_or("-");
665 let usd = addr
666 .usd
667 .map(|u| format!("${:.2}", u))
668 .unwrap_or_else(|| "-".to_string());
669 let token_list: String = addr
670 .tokens
671 .iter()
672 .map(|t| t.symbol.as_deref().unwrap_or(&t.contract_address))
673 .take(3)
674 .collect::<Vec<_>>()
675 .join(", ");
676 let tokens_display = if addr.tokens.len() > 3 {
677 format!("{} (+{})", token_list, addr.tokens.len() - 3)
678 } else {
679 token_list
680 };
681 md.push_str(&format!(
682 "| `{}` | {} | {} | {} | {} | {} |\n",
683 addr.address,
684 label,
685 addr.chain,
686 addr.balance,
687 usd,
688 if tokens_display.is_empty() {
689 "-"
690 } else {
691 &tokens_display
692 }
693 ));
694 }
695
696 md.push_str(&crate::display::report::report_footer());
697 md
698}
699
700async fn fetch_address_balance(
702 address: &str,
703 chain: &str,
704 clients: &dyn ChainClientFactory,
705 _include_tokens: bool,
706) -> (String, Vec<TokenSummary>) {
707 let client = match clients.create_chain_client(chain) {
708 Ok(c) => c,
709 Err(e) => {
710 eprintln!(" ⚠ Unsupported chain: {}", chain);
711 tracing::debug!(error = %e, chain = %chain, "Failed to create chain client");
712 return ("Error".to_string(), vec![]);
713 }
714 };
715
716 let native_balance = match client.get_balance(address).await {
718 Ok(bal) => bal.formatted,
719 Err(e) => {
720 eprintln!(" ⚠ Could not fetch balance for {}", address);
721 tracing::debug!(error = %e, address = %address, "Failed to fetch balance");
722 "Error".to_string()
723 }
724 };
725
726 let tokens = match client.get_token_balances(address).await {
728 Ok(token_bals) => token_bals
729 .into_iter()
730 .map(|tb| TokenSummary {
731 contract_address: tb.token.contract_address,
732 balance: tb.formatted_balance,
733 decimals: tb.token.decimals,
734 symbol: Some(tb.token.symbol),
735 })
736 .collect(),
737 Err(e) => {
738 eprintln!(" ⚠ Token balances unavailable");
739 tracing::debug!(error = %e, "Could not fetch token balances");
740 vec![]
741 }
742 };
743
744 (native_balance, tokens)
745}
746
747#[cfg(test)]
752mod tests {
753 use super::*;
754 use tempfile::TempDir;
755
756 fn create_test_address_book() -> AddressBook {
757 AddressBook {
758 addresses: vec![
759 WatchedAddress {
760 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
761 label: Some("Main Wallet".to_string()),
762 chain: "ethereum".to_string(),
763 tags: vec!["personal".to_string()],
764 added_at: 1700000000,
765 },
766 WatchedAddress {
767 address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
768 label: None,
769 chain: "polygon".to_string(),
770 tags: vec![],
771 added_at: 1700000001,
772 },
773 ],
774 }
775 }
776
777 #[test]
778 fn test_address_book_default() {
779 let address_book = AddressBook::default();
780 assert!(address_book.addresses.is_empty());
781 }
782
783 #[test]
784 fn test_address_book_add_address() {
785 let mut address_book = AddressBook::default();
786
787 let watched = WatchedAddress {
788 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
789 label: Some("Test".to_string()),
790 chain: "ethereum".to_string(),
791 tags: vec![],
792 added_at: 0,
793 };
794
795 let result = address_book.add_address(watched);
796 assert!(result.is_ok());
797 assert_eq!(address_book.addresses.len(), 1);
798 }
799
800 #[test]
801 fn test_address_book_add_duplicate_fails() {
802 let mut address_book = AddressBook::default();
803
804 let watched1 = WatchedAddress {
805 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
806 label: Some("First".to_string()),
807 chain: "ethereum".to_string(),
808 tags: vec![],
809 added_at: 0,
810 };
811
812 let watched2 = WatchedAddress {
813 address: "0x742d35CC6634C0532925A3b844Bc9e7595f1b3c2".to_string(), label: Some("Second".to_string()),
815 chain: "ethereum".to_string(),
816 tags: vec![],
817 added_at: 0,
818 };
819
820 address_book.add_address(watched1).unwrap();
821 let result = address_book.add_address(watched2);
822
823 assert!(result.is_err());
824 assert!(
825 result
826 .unwrap_err()
827 .to_string()
828 .contains("already in address book")
829 );
830 }
831
832 #[test]
833 fn test_address_book_remove_address() {
834 let mut address_book = create_test_address_book();
835 let original_len = address_book.addresses.len();
836
837 let removed = address_book
838 .remove_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2")
839 .unwrap();
840
841 assert!(removed);
842 assert_eq!(address_book.addresses.len(), original_len - 1);
843 }
844
845 #[test]
846 fn test_address_book_remove_nonexistent() {
847 let mut address_book = create_test_address_book();
848 let original_len = address_book.addresses.len();
849
850 let removed = address_book.remove_address("0xnonexistent").unwrap();
851
852 assert!(!removed);
853 assert_eq!(address_book.addresses.len(), original_len);
854 }
855
856 #[test]
857 fn test_address_book_find_address() {
858 let address_book = create_test_address_book();
859
860 let found = address_book.find_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
861 assert!(found.is_some());
862 assert_eq!(found.unwrap().label, Some("Main Wallet".to_string()));
863
864 let not_found = address_book.find_address("0xnonexistent");
865 assert!(not_found.is_none());
866 }
867
868 #[test]
869 fn test_address_book_find_address_case_insensitive() {
870 let address_book = create_test_address_book();
871
872 let found = address_book.find_address("0x742D35CC6634C0532925A3B844BC9E7595F1B3C2");
873 assert!(found.is_some());
874 }
875
876 #[test]
877 fn test_address_book_save_and_load() {
878 let temp_dir = TempDir::new().unwrap();
879 let data_dir = temp_dir.path().to_path_buf();
880
881 let address_book = create_test_address_book();
882 address_book.save(&data_dir).unwrap();
883
884 let loaded = AddressBook::load(&data_dir).unwrap();
885 assert_eq!(loaded.addresses.len(), address_book.addresses.len());
886 assert_eq!(
887 loaded.addresses[0].address,
888 address_book.addresses[0].address
889 );
890 }
891
892 #[test]
893 fn test_address_book_load_nonexistent_returns_default() {
894 let temp_dir = TempDir::new().unwrap();
895 let data_dir = temp_dir.path().to_path_buf();
896
897 let address_book = AddressBook::load(&data_dir).unwrap();
898 assert!(address_book.addresses.is_empty());
899 }
900
901 #[test]
902 fn test_watched_address_serialization() {
903 let watched = WatchedAddress {
904 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
905 label: Some("Test".to_string()),
906 chain: "ethereum".to_string(),
907 tags: vec!["tag1".to_string(), "tag2".to_string()],
908 added_at: 1700000000,
909 };
910
911 let json = serde_json::to_string(&watched).unwrap();
912 assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
913 assert!(json.contains("Test"));
914 assert!(json.contains("tag1"));
915
916 let deserialized: WatchedAddress = serde_json::from_str(&json).unwrap();
917 assert_eq!(deserialized.address, watched.address);
918 assert_eq!(deserialized.tags.len(), 2);
919 }
920
921 #[test]
922 fn test_address_book_summary_serialization() {
923 let summary = AddressBookSummary {
924 address_count: 2,
925 balances_by_chain: HashMap::new(),
926 total_usd: Some(10000.0),
927 addresses: vec![AddressSummary {
928 address: "0x123".to_string(),
929 label: Some("Test".to_string()),
930 chain: "ethereum".to_string(),
931 balance: "1.5".to_string(),
932 usd: Some(5000.0),
933 tokens: vec![],
934 }],
935 };
936
937 let json = serde_json::to_string(&summary).unwrap();
938 assert!(json.contains("10000"));
939 assert!(json.contains("0x123"));
940 }
941
942 #[test]
943 fn test_address_book_args_parsing() {
944 use clap::Parser;
945
946 #[derive(Parser)]
947 struct TestCli {
948 #[command(flatten)]
949 args: AddressBookArgs,
950 }
951
952 let cli = TestCli::try_parse_from(["test", "list"]).unwrap();
953 assert!(matches!(cli.args.command, AddressBookCommands::List));
954 }
955
956 #[test]
957 fn test_address_book_add_args_parsing() {
958 use clap::Parser;
959
960 #[derive(Parser)]
961 struct TestCli {
962 #[command(flatten)]
963 args: AddressBookArgs,
964 }
965
966 let cli = TestCli::try_parse_from([
967 "test",
968 "add",
969 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
970 "--label",
971 "My Wallet",
972 "--chain",
973 "polygon",
974 "--tags",
975 "personal,defi",
976 ])
977 .unwrap();
978
979 if let AddressBookCommands::Add(add_args) = cli.args.command {
980 assert_eq!(
981 add_args.address,
982 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
983 );
984 assert_eq!(add_args.label, Some("My Wallet".to_string()));
985 assert_eq!(add_args.chain, "polygon");
986 assert_eq!(add_args.tags, vec!["personal", "defi"]);
987 } else {
988 panic!("Expected Add command");
989 }
990 }
991
992 #[test]
993 fn test_chain_balance_serialization() {
994 let balance = ChainBalance {
995 native_balance: "10.5".to_string(),
996 symbol: "ETH".to_string(),
997 usd: Some(35000.0),
998 };
999
1000 let json = serde_json::to_string(&balance).unwrap();
1001 assert!(json.contains("10.5"));
1002 assert!(json.contains("ETH"));
1003 assert!(json.contains("35000"));
1004 }
1005
1006 #[test]
1011 fn test_get_native_symbol_solana() {
1012 assert_eq!(native_symbol("solana"), "SOL");
1013 assert_eq!(native_symbol("sol"), "SOL");
1014 }
1015
1016 #[test]
1017 fn test_get_native_symbol_ethereum() {
1018 assert_eq!(native_symbol("ethereum"), "ETH");
1019 assert_eq!(native_symbol("eth"), "ETH");
1020 }
1021
1022 #[test]
1023 fn test_get_native_symbol_tron() {
1024 assert_eq!(native_symbol("tron"), "TRX");
1025 assert_eq!(native_symbol("trx"), "TRX");
1026 }
1027
1028 #[test]
1029 fn test_get_native_symbol_unknown() {
1030 assert_eq!(native_symbol("bitcoin"), "???");
1031 assert_eq!(native_symbol("unknown"), "???");
1032 }
1033
1034 use crate::chains::mocks::{MockClientFactory, MockDexSource};
1039 use crate::chains::{ChainClient, ChainClientFactory, DexDataSource};
1040
1041 fn mock_factory() -> MockClientFactory {
1042 MockClientFactory::new()
1043 }
1044
1045 struct FailingChainClientFactory;
1047
1048 impl ChainClientFactory for FailingChainClientFactory {
1049 fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>> {
1050 Err(ScopeError::Chain(format!("unsupported chain: {}", chain)))
1051 }
1052
1053 fn create_dex_client(&self) -> Box<dyn DexDataSource> {
1054 Box::new(MockDexSource::new())
1055 }
1056 }
1057
1058 #[tokio::test]
1059 async fn test_run_address_book_list_empty() {
1060 let tmp_dir = tempfile::tempdir().unwrap();
1061 let config = Config {
1062 address_book: crate::config::AddressBookConfig {
1063 data_dir: Some(tmp_dir.path().to_path_buf()),
1064 },
1065 ..Default::default()
1066 };
1067 let factory = mock_factory();
1068 let args = AddressBookArgs {
1069 command: AddressBookCommands::List,
1070 format: Some(OutputFormat::Table),
1071 };
1072 let result = super::run(args, &config, &factory).await;
1073 assert!(result.is_ok());
1074 }
1075
1076 #[tokio::test]
1077 async fn test_run_address_book_add_and_list() {
1078 let tmp_dir = tempfile::tempdir().unwrap();
1079 let config = Config {
1080 address_book: crate::config::AddressBookConfig {
1081 data_dir: Some(tmp_dir.path().to_path_buf()),
1082 },
1083 ..Default::default()
1084 };
1085 let factory = mock_factory();
1086
1087 let add_args = AddressBookArgs {
1089 command: AddressBookCommands::Add(AddArgs {
1090 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1091 label: Some("Test Wallet".to_string()),
1092 chain: "ethereum".to_string(),
1093 tags: vec!["test".to_string()],
1094 }),
1095 format: Some(OutputFormat::Table),
1096 };
1097 let result = super::run(add_args, &config, &factory).await;
1098 assert!(result.is_ok());
1099
1100 let list_args = AddressBookArgs {
1102 command: AddressBookCommands::List,
1103 format: Some(OutputFormat::Json),
1104 };
1105 let result = super::run(list_args, &config, &factory).await;
1106 assert!(result.is_ok());
1107 }
1108
1109 #[tokio::test]
1110 async fn test_run_address_book_summary_with_mock() {
1111 let tmp_dir = tempfile::tempdir().unwrap();
1112 let config = Config {
1113 address_book: crate::config::AddressBookConfig {
1114 data_dir: Some(tmp_dir.path().to_path_buf()),
1115 },
1116 ..Default::default()
1117 };
1118 let factory = mock_factory();
1119
1120 let add_args = AddressBookArgs {
1122 command: AddressBookCommands::Add(AddArgs {
1123 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1124 label: Some("Test".to_string()),
1125 chain: "ethereum".to_string(),
1126 tags: vec![],
1127 }),
1128 format: None,
1129 };
1130 super::run(add_args, &config, &factory).await.unwrap();
1131
1132 let summary_args = AddressBookArgs {
1134 command: AddressBookCommands::Summary(SummaryArgs {
1135 chain: None,
1136 tag: None,
1137 include_tokens: false,
1138 report: None,
1139 }),
1140 format: Some(OutputFormat::Json),
1141 };
1142 let result = super::run(summary_args, &config, &factory).await;
1143 assert!(result.is_ok());
1144 }
1145
1146 #[tokio::test]
1147 async fn test_run_address_book_remove() {
1148 let tmp_dir = tempfile::tempdir().unwrap();
1149 let config = Config {
1150 address_book: crate::config::AddressBookConfig {
1151 data_dir: Some(tmp_dir.path().to_path_buf()),
1152 },
1153 ..Default::default()
1154 };
1155 let factory = mock_factory();
1156
1157 let add_args = AddressBookArgs {
1159 command: AddressBookCommands::Add(AddArgs {
1160 address: "0xtest".to_string(),
1161 label: None,
1162 chain: "ethereum".to_string(),
1163 tags: vec![],
1164 }),
1165 format: None,
1166 };
1167 super::run(add_args, &config, &factory).await.unwrap();
1168
1169 let remove_args = AddressBookArgs {
1170 command: AddressBookCommands::Remove(RemoveArgs {
1171 address: "0xtest".to_string(),
1172 }),
1173 format: None,
1174 };
1175 let result = super::run(remove_args, &config, &factory).await;
1176 assert!(result.is_ok());
1177 }
1178
1179 #[tokio::test]
1180 async fn test_run_address_book_summary_csv() {
1181 let tmp_dir = tempfile::tempdir().unwrap();
1182 let config = Config {
1183 address_book: crate::config::AddressBookConfig {
1184 data_dir: Some(tmp_dir.path().to_path_buf()),
1185 },
1186 ..Default::default()
1187 };
1188 let factory = mock_factory();
1189
1190 let add_args = AddressBookArgs {
1192 command: AddressBookCommands::Add(AddArgs {
1193 address: "0xtest".to_string(),
1194 label: Some("TestAddr".to_string()),
1195 chain: "ethereum".to_string(),
1196 tags: vec!["defi".to_string()],
1197 }),
1198 format: None,
1199 };
1200 super::run(add_args, &config, &factory).await.unwrap();
1201
1202 let summary_args = AddressBookArgs {
1204 command: AddressBookCommands::Summary(SummaryArgs {
1205 chain: None,
1206 tag: None,
1207 include_tokens: false,
1208 report: None,
1209 }),
1210 format: Some(OutputFormat::Csv),
1211 };
1212 let result = super::run(summary_args, &config, &factory).await;
1213 assert!(result.is_ok());
1214 }
1215
1216 #[tokio::test]
1217 async fn test_run_address_book_summary_table() {
1218 let tmp_dir = tempfile::tempdir().unwrap();
1219 let config = Config {
1220 address_book: crate::config::AddressBookConfig {
1221 data_dir: Some(tmp_dir.path().to_path_buf()),
1222 },
1223 ..Default::default()
1224 };
1225 let factory = mock_factory();
1226
1227 let add_args = AddressBookArgs {
1229 command: AddressBookCommands::Add(AddArgs {
1230 address: "0xtest".to_string(),
1231 label: Some("TestAddr".to_string()),
1232 chain: "ethereum".to_string(),
1233 tags: vec![],
1234 }),
1235 format: None,
1236 };
1237 super::run(add_args, &config, &factory).await.unwrap();
1238
1239 let summary_args = AddressBookArgs {
1241 command: AddressBookCommands::Summary(SummaryArgs {
1242 chain: None,
1243 tag: None,
1244 include_tokens: true,
1245 report: None,
1246 }),
1247 format: Some(OutputFormat::Table),
1248 };
1249 let result = super::run(summary_args, &config, &factory).await;
1250 assert!(result.is_ok());
1251 }
1252
1253 #[tokio::test]
1254 async fn test_run_address_book_summary_with_chain_filter() {
1255 let tmp_dir = tempfile::tempdir().unwrap();
1256 let config = Config {
1257 address_book: crate::config::AddressBookConfig {
1258 data_dir: Some(tmp_dir.path().to_path_buf()),
1259 },
1260 ..Default::default()
1261 };
1262 let factory = mock_factory();
1263
1264 let add_eth = AddressBookArgs {
1266 command: AddressBookCommands::Add(AddArgs {
1267 address: "0xeth".to_string(),
1268 label: None,
1269 chain: "ethereum".to_string(),
1270 tags: vec![],
1271 }),
1272 format: None,
1273 };
1274 super::run(add_eth, &config, &factory).await.unwrap();
1275
1276 let add_poly = AddressBookArgs {
1277 command: AddressBookCommands::Add(AddArgs {
1278 address: "0xpoly".to_string(),
1279 label: None,
1280 chain: "polygon".to_string(),
1281 tags: vec![],
1282 }),
1283 format: None,
1284 };
1285 super::run(add_poly, &config, &factory).await.unwrap();
1286
1287 let summary_args = AddressBookArgs {
1289 command: AddressBookCommands::Summary(SummaryArgs {
1290 chain: Some("ethereum".to_string()),
1291 tag: None,
1292 include_tokens: false,
1293 report: None,
1294 }),
1295 format: Some(OutputFormat::Json),
1296 };
1297 let result = super::run(summary_args, &config, &factory).await;
1298 assert!(result.is_ok());
1299 }
1300
1301 #[tokio::test]
1302 async fn test_run_address_book_summary_with_tag_filter() {
1303 let tmp_dir = tempfile::tempdir().unwrap();
1304 let config = Config {
1305 address_book: crate::config::AddressBookConfig {
1306 data_dir: Some(tmp_dir.path().to_path_buf()),
1307 },
1308 ..Default::default()
1309 };
1310 let factory = mock_factory();
1311
1312 let add_args = AddressBookArgs {
1314 command: AddressBookCommands::Add(AddArgs {
1315 address: "0xdefi".to_string(),
1316 label: None,
1317 chain: "ethereum".to_string(),
1318 tags: vec!["defi".to_string()],
1319 }),
1320 format: None,
1321 };
1322 super::run(add_args, &config, &factory).await.unwrap();
1323
1324 let summary_args = AddressBookArgs {
1326 command: AddressBookCommands::Summary(SummaryArgs {
1327 chain: None,
1328 tag: Some("defi".to_string()),
1329 include_tokens: false,
1330 report: None,
1331 }),
1332 format: Some(OutputFormat::Json),
1333 };
1334 let result = super::run(summary_args, &config, &factory).await;
1335 assert!(result.is_ok());
1336 }
1337
1338 #[tokio::test]
1339 async fn test_run_address_book_summary_no_format() {
1340 let tmp_dir = tempfile::tempdir().unwrap();
1341 let config = Config {
1342 address_book: crate::config::AddressBookConfig {
1343 data_dir: Some(tmp_dir.path().to_path_buf()),
1344 },
1345 ..Default::default()
1346 };
1347 let factory = mock_factory();
1348
1349 let add_args = AddressBookArgs {
1350 command: AddressBookCommands::Add(AddArgs {
1351 address: "0xtest".to_string(),
1352 label: None,
1353 chain: "ethereum".to_string(),
1354 tags: vec![],
1355 }),
1356 format: None,
1357 };
1358 super::run(add_args, &config, &factory).await.unwrap();
1359
1360 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: None, };
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_summary_empty() {
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 summary_args = AddressBookArgs {
1386 command: AddressBookCommands::Summary(SummaryArgs {
1387 chain: None,
1388 tag: None,
1389 include_tokens: false,
1390 report: None,
1391 }),
1392 format: Some(OutputFormat::Table),
1393 };
1394 let result = super::run(summary_args, &config, &factory).await;
1395 assert!(result.is_ok());
1396 }
1397
1398 #[tokio::test]
1399 async fn test_run_address_book_add_with_tags() {
1400 let tmp_dir = tempfile::tempdir().unwrap();
1401 let config = Config {
1402 address_book: crate::config::AddressBookConfig {
1403 data_dir: Some(tmp_dir.path().to_path_buf()),
1404 },
1405 ..Default::default()
1406 };
1407 let factory = mock_factory();
1408
1409 let add_args = AddressBookArgs {
1410 command: AddressBookCommands::Add(AddArgs {
1411 address: "0xtagged".to_string(),
1412 label: Some("Tagged".to_string()),
1413 chain: "ethereum".to_string(),
1414 tags: vec!["defi".to_string(), "whale".to_string()],
1415 }),
1416 format: None,
1417 };
1418 let result = super::run(add_args, &config, &factory).await;
1419 assert!(result.is_ok());
1420 }
1421
1422 #[test]
1423 fn test_get_native_symbol_polygon() {
1424 assert_eq!(native_symbol("polygon"), "MATIC");
1425 }
1426
1427 #[test]
1428 fn test_get_native_symbol_bsc() {
1429 assert_eq!(native_symbol("bsc"), "BNB");
1430 }
1431
1432 #[test]
1433 fn test_get_native_symbol_evm_l2s() {
1434 assert_eq!(native_symbol("arbitrum"), "ETH");
1435 assert_eq!(native_symbol("optimism"), "ETH");
1436 assert_eq!(native_symbol("base"), "ETH");
1437 }
1438
1439 #[tokio::test]
1440 async fn test_run_address_book_list_csv_format() {
1441 let tmp_dir = tempfile::tempdir().unwrap();
1442 let config = Config {
1443 address_book: crate::config::AddressBookConfig {
1444 data_dir: Some(tmp_dir.path().to_path_buf()),
1445 },
1446 ..Default::default()
1447 };
1448 let factory = mock_factory();
1449
1450 let add_args = AddressBookArgs {
1452 command: AddressBookCommands::Add(AddArgs {
1453 address: "0xCSV_test".to_string(),
1454 label: Some("CsvAddr".to_string()),
1455 chain: "ethereum".to_string(),
1456 tags: vec!["test".to_string()],
1457 }),
1458 format: None,
1459 };
1460 super::run(add_args, &config, &factory).await.unwrap();
1461
1462 let list_args = AddressBookArgs {
1464 command: AddressBookCommands::List,
1465 format: Some(OutputFormat::Csv),
1466 };
1467 let result = super::run(list_args, &config, &factory).await;
1468 assert!(result.is_ok());
1469 }
1470
1471 #[tokio::test]
1472 async fn test_run_address_book_list_table_format() {
1473 let tmp_dir = tempfile::tempdir().unwrap();
1474 let config = Config {
1475 address_book: crate::config::AddressBookConfig {
1476 data_dir: Some(tmp_dir.path().to_path_buf()),
1477 },
1478 ..Default::default()
1479 };
1480 let factory = mock_factory();
1481
1482 let add_args = AddressBookArgs {
1484 command: AddressBookCommands::Add(AddArgs {
1485 address: "0xTable_test1".to_string(),
1486 label: Some("LabeledAddr".to_string()),
1487 chain: "ethereum".to_string(),
1488 tags: vec!["personal".to_string(), "defi".to_string()],
1489 }),
1490 format: None,
1491 };
1492 super::run(add_args, &config, &factory).await.unwrap();
1493
1494 let add_args2 = AddressBookArgs {
1495 command: AddressBookCommands::Add(AddArgs {
1496 address: "0xTable_test2".to_string(),
1497 label: None,
1498 chain: "polygon".to_string(),
1499 tags: vec![],
1500 }),
1501 format: None,
1502 };
1503 super::run(add_args2, &config, &factory).await.unwrap();
1504
1505 let list_args = AddressBookArgs {
1507 command: AddressBookCommands::List,
1508 format: Some(OutputFormat::Table),
1509 };
1510 let result = super::run(list_args, &config, &factory).await;
1511 assert!(result.is_ok());
1512 }
1513
1514 #[tokio::test]
1515 async fn test_run_address_book_summary_table_with_tokens() {
1516 let tmp_dir = tempfile::tempdir().unwrap();
1517 let config = Config {
1518 address_book: crate::config::AddressBookConfig {
1519 data_dir: Some(tmp_dir.path().to_path_buf()),
1520 },
1521 ..Default::default()
1522 };
1523 let factory = mock_factory();
1524
1525 let add_args = AddressBookArgs {
1527 command: AddressBookCommands::Add(AddArgs {
1528 address: "0xTokenTest".to_string(),
1529 label: Some("TokenAddr".to_string()),
1530 chain: "ethereum".to_string(),
1531 tags: vec![],
1532 }),
1533 format: None,
1534 };
1535 super::run(add_args, &config, &factory).await.unwrap();
1536
1537 let summary_args = AddressBookArgs {
1539 command: AddressBookCommands::Summary(SummaryArgs {
1540 chain: None,
1541 tag: None,
1542 include_tokens: true,
1543 report: None,
1544 }),
1545 format: Some(OutputFormat::Table),
1546 };
1547 let result = super::run(summary_args, &config, &factory).await;
1548 assert!(result.is_ok());
1549 }
1550
1551 #[tokio::test]
1552 async fn test_run_address_book_summary_multiple_chains() {
1553 let tmp_dir = tempfile::tempdir().unwrap();
1554 let config = Config {
1555 address_book: crate::config::AddressBookConfig {
1556 data_dir: Some(tmp_dir.path().to_path_buf()),
1557 },
1558 ..Default::default()
1559 };
1560 let factory = mock_factory();
1561
1562 let add1 = AddressBookArgs {
1564 command: AddressBookCommands::Add(AddArgs {
1565 address: "0xMulti1".to_string(),
1566 label: None,
1567 chain: "ethereum".to_string(),
1568 tags: vec![],
1569 }),
1570 format: None,
1571 };
1572 super::run(add1, &config, &factory).await.unwrap();
1573
1574 let add2 = AddressBookArgs {
1575 command: AddressBookCommands::Add(AddArgs {
1576 address: "0xMulti2".to_string(),
1577 label: None,
1578 chain: "ethereum".to_string(),
1579 tags: vec![],
1580 }),
1581 format: None,
1582 };
1583 super::run(add2, &config, &factory).await.unwrap();
1584
1585 let summary_args = AddressBookArgs {
1587 command: AddressBookCommands::Summary(SummaryArgs {
1588 chain: None,
1589 tag: None,
1590 include_tokens: false,
1591 report: None,
1592 }),
1593 format: Some(OutputFormat::Table),
1594 };
1595 let result = super::run(summary_args, &config, &factory).await;
1596 assert!(result.is_ok());
1597 }
1598
1599 #[tokio::test]
1600 async fn test_run_address_book_list_no_format() {
1601 let tmp_dir = tempfile::tempdir().unwrap();
1602 let config = Config {
1603 address_book: crate::config::AddressBookConfig {
1604 data_dir: Some(tmp_dir.path().to_path_buf()),
1605 },
1606 ..Default::default()
1607 };
1608 let factory = mock_factory();
1609
1610 let add_args = AddressBookArgs {
1612 command: AddressBookCommands::Add(AddArgs {
1613 address: "0xNoFmt".to_string(),
1614 label: Some("Test".to_string()),
1615 chain: "ethereum".to_string(),
1616 tags: vec![],
1617 }),
1618 format: None,
1619 };
1620 super::run(add_args, &config, &factory).await.unwrap();
1621
1622 let list_args = AddressBookArgs {
1624 command: AddressBookCommands::List,
1625 format: None,
1626 };
1627 let result = super::run(list_args, &config, &factory).await;
1628 assert!(result.is_ok());
1629 }
1630
1631 #[test]
1632 fn test_address_book_new() {
1633 let p = AddressBook::default();
1634 assert!(p.addresses.is_empty());
1635 }
1636
1637 #[test]
1638 fn test_address_book_load_missing_dir() {
1639 let temp = tempfile::tempdir().unwrap();
1640 let p = AddressBook::load(temp.path());
1641 assert!(p.is_ok());
1642 assert!(p.unwrap().addresses.is_empty());
1643 }
1644
1645 #[test]
1646 fn test_address_book_add_and_save_roundtrip() {
1647 let temp = tempfile::tempdir().unwrap();
1648 let mut p = AddressBook::default();
1649 let addr = WatchedAddress {
1650 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1651 label: Some("Test".to_string()),
1652 chain: "ethereum".to_string(),
1653 tags: vec!["tag1".to_string()],
1654 added_at: 1234567890,
1655 };
1656 p.add_address(addr).unwrap();
1657 assert_eq!(p.addresses.len(), 1);
1658
1659 let data_dir = temp.path().to_path_buf();
1660 p.save(&data_dir).unwrap();
1661 let loaded = AddressBook::load(temp.path()).unwrap();
1662 assert_eq!(loaded.addresses.len(), 1);
1663 assert_eq!(
1664 loaded.addresses[0].address,
1665 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1666 );
1667 assert_eq!(loaded.addresses[0].label, Some("Test".to_string()));
1668 }
1669
1670 #[test]
1671 fn test_address_book_add_duplicate() {
1672 let mut p = AddressBook::default();
1673 let addr1 = WatchedAddress {
1674 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1675 label: None,
1676 chain: "ethereum".to_string(),
1677 tags: vec![],
1678 added_at: 0,
1679 };
1680 let addr2 = WatchedAddress {
1681 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1682 label: None,
1683 chain: "ethereum".to_string(),
1684 tags: vec![],
1685 added_at: 0,
1686 };
1687 p.add_address(addr1).unwrap();
1688 let result = p.add_address(addr2);
1689 assert!(result.is_err());
1691 assert!(
1692 result
1693 .unwrap_err()
1694 .to_string()
1695 .contains("already in address book")
1696 );
1697 }
1698
1699 #[test]
1700 fn test_watched_address_debug() {
1701 let addr = WatchedAddress {
1702 address: "0xtest".to_string(),
1703 label: Some("My Wallet".to_string()),
1704 chain: "ethereum".to_string(),
1705 tags: vec!["defi".to_string(), "staking".to_string()],
1706 added_at: 1700000000,
1707 };
1708 let debug = format!("{:?}", addr);
1709 assert!(debug.contains("WatchedAddress"));
1710 assert!(debug.contains("0xtest"));
1711 }
1712
1713 #[test]
1718 fn test_address_book_summary_to_markdown_basic() {
1719 let mut balances_by_chain = HashMap::new();
1720 balances_by_chain.insert(
1721 "ethereum".to_string(),
1722 ChainBalance {
1723 native_balance: "1.5".to_string(),
1724 symbol: "ETH".to_string(),
1725 usd: None,
1726 },
1727 );
1728
1729 let summary = AddressBookSummary {
1730 address_count: 2,
1731 balances_by_chain,
1732 total_usd: None,
1733 addresses: vec![
1734 AddressSummary {
1735 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1736 label: Some("Main Wallet".to_string()),
1737 chain: "ethereum".to_string(),
1738 balance: "1.5".to_string(),
1739 usd: None,
1740 tokens: vec![],
1741 },
1742 AddressSummary {
1743 address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
1744 label: None,
1745 chain: "polygon".to_string(),
1746 balance: "100.0".to_string(),
1747 usd: None,
1748 tokens: vec![],
1749 },
1750 ],
1751 };
1752
1753 let md = address_book_summary_to_markdown(&summary);
1754
1755 assert!(md.contains("# Address Book Report"));
1757 assert!(md.contains("**Addresses:** 2"));
1758 assert!(md.contains("Allocation by Chain"));
1759 assert!(md.contains("## Addresses"));
1760
1761 assert!(md.contains("ethereum"));
1763 assert!(md.contains("1.5"));
1764 assert!(md.contains("ETH"));
1765
1766 assert!(md.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
1768 assert!(md.contains("Main Wallet"));
1769 assert!(md.contains("0xABCdef1234567890abcdef1234567890ABCDEF12"));
1770 assert!(md.contains("polygon"));
1771 assert!(md.contains("100.0"));
1772
1773 assert!(md.contains("Report generated by Scope"));
1775 }
1776
1777 #[test]
1778 fn test_address_book_summary_to_markdown_with_usd() {
1779 let mut balances_by_chain = HashMap::new();
1780 balances_by_chain.insert(
1781 "ethereum".to_string(),
1782 ChainBalance {
1783 native_balance: "2.0".to_string(),
1784 symbol: "ETH".to_string(),
1785 usd: Some(3000.0),
1786 },
1787 );
1788
1789 let summary = AddressBookSummary {
1790 address_count: 2,
1791 balances_by_chain,
1792 total_usd: Some(5000.0),
1793 addresses: vec![
1794 AddressSummary {
1795 address: "0x1234567890123456789012345678901234567890".to_string(),
1796 label: Some("Wallet 1".to_string()),
1797 chain: "ethereum".to_string(),
1798 balance: "2.0".to_string(),
1799 usd: Some(3000.0),
1800 tokens: vec![],
1801 },
1802 AddressSummary {
1803 address: "0x0987654321098765432109876543210987654321".to_string(),
1804 label: Some("Wallet 2".to_string()),
1805 chain: "ethereum".to_string(),
1806 balance: "1.0".to_string(),
1807 usd: Some(2000.0),
1808 tokens: vec![],
1809 },
1810 ],
1811 };
1812
1813 let md = address_book_summary_to_markdown(&summary);
1814
1815 assert!(md.contains("**Total Value (USD):** $5000.00"));
1817
1818 assert!(md.contains("$3000.00"));
1820
1821 assert!(md.contains("$3000.00"));
1823 assert!(md.contains("$2000.00"));
1824 }
1825
1826 #[test]
1827 fn test_address_book_summary_to_markdown_with_tokens() {
1828 let mut balances_by_chain = HashMap::new();
1829 balances_by_chain.insert(
1830 "ethereum".to_string(),
1831 ChainBalance {
1832 native_balance: "1.0".to_string(),
1833 symbol: "ETH".to_string(),
1834 usd: None,
1835 },
1836 );
1837
1838 let tokens = vec![
1840 TokenSummary {
1841 contract_address: "0xToken1".to_string(),
1842 balance: "100.0".to_string(),
1843 decimals: 18,
1844 symbol: Some("USDC".to_string()),
1845 },
1846 TokenSummary {
1847 contract_address: "0xToken2".to_string(),
1848 balance: "50.0".to_string(),
1849 decimals: 18,
1850 symbol: Some("DAI".to_string()),
1851 },
1852 TokenSummary {
1853 contract_address: "0xToken3".to_string(),
1854 balance: "25.0".to_string(),
1855 decimals: 18,
1856 symbol: Some("WBTC".to_string()),
1857 },
1858 TokenSummary {
1859 contract_address: "0xToken4".to_string(),
1860 balance: "10.0".to_string(),
1861 decimals: 18,
1862 symbol: Some("UNI".to_string()),
1863 },
1864 TokenSummary {
1865 contract_address: "0xToken5".to_string(),
1866 balance: "5.0".to_string(),
1867 decimals: 18,
1868 symbol: None, },
1870 ];
1871
1872 let summary = AddressBookSummary {
1873 address_count: 1,
1874 balances_by_chain,
1875 total_usd: None,
1876 addresses: vec![AddressSummary {
1877 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1878 label: Some("Token Wallet".to_string()),
1879 chain: "ethereum".to_string(),
1880 balance: "1.0".to_string(),
1881 usd: None,
1882 tokens,
1883 }],
1884 };
1885
1886 let md = address_book_summary_to_markdown(&summary);
1887
1888 assert!(md.contains("USDC"));
1890 assert!(md.contains("DAI"));
1891 assert!(md.contains("WBTC"));
1892
1893 assert!(md.contains("+2"));
1895
1896 }
1901
1902 #[test]
1903 fn test_address_book_summary_to_markdown_empty() {
1904 let summary = AddressBookSummary {
1905 address_count: 0,
1906 balances_by_chain: HashMap::new(),
1907 total_usd: None,
1908 addresses: vec![],
1909 };
1910
1911 let md = address_book_summary_to_markdown(&summary);
1912
1913 assert!(md.contains("# Address Book Report"));
1915 assert!(md.contains("**Addresses:** 0"));
1916
1917 assert!(md.contains("Allocation by Chain"));
1919
1920 assert!(md.contains("## Addresses"));
1922
1923 assert!(md.contains("Report generated by Scope"));
1925 }
1926
1927 #[test]
1932 fn test_find_by_label_exact_match() {
1933 let address_book = create_test_address_book();
1934 let found = address_book.find_by_label("Main Wallet");
1935 assert!(found.is_some());
1936 assert_eq!(
1937 found.unwrap().address,
1938 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1939 );
1940 }
1941
1942 #[test]
1943 fn test_find_by_label_case_insensitive() {
1944 let address_book = create_test_address_book();
1945 let found = address_book.find_by_label("main wallet");
1946 assert!(found.is_some());
1947 assert_eq!(
1948 found.unwrap().address,
1949 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1950 );
1951 }
1952
1953 #[test]
1954 fn test_find_by_label_with_whitespace() {
1955 let address_book = create_test_address_book();
1956 let found = address_book.find_by_label(" Main Wallet ");
1957 assert!(found.is_some());
1958 }
1959
1960 #[test]
1961 fn test_find_by_label_not_found() {
1962 let address_book = create_test_address_book();
1963 let found = address_book.find_by_label("nonexistent");
1964 assert!(found.is_none());
1965 }
1966
1967 #[test]
1968 fn test_find_by_label_no_label_entries() {
1969 let address_book = create_test_address_book();
1970 let found = address_book.find_by_label("");
1972 assert!(found.is_none());
1973 }
1974
1975 #[test]
1976 fn test_find_by_label_empty_address_book() {
1977 let address_book = AddressBook::default();
1978 let found = address_book.find_by_label("anything");
1979 assert!(found.is_none());
1980 }
1981
1982 #[test]
1987 fn test_resolve_address_book_input_by_label() {
1988 let tmp_dir = TempDir::new().unwrap();
1989 let mut address_book = AddressBook::default();
1990 address_book
1991 .add_address(WatchedAddress {
1992 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1993 label: Some("hot-wallet".to_string()),
1994 chain: "ethereum".to_string(),
1995 tags: vec![],
1996 added_at: 0,
1997 })
1998 .unwrap();
1999 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
2000
2001 let config = Config {
2002 address_book: crate::config::AddressBookConfig {
2003 data_dir: Some(tmp_dir.path().to_path_buf()),
2004 },
2005 ..Default::default()
2006 };
2007
2008 let result = resolve_address_book_input("@hot-wallet", &config).unwrap();
2009 assert!(result.is_some());
2010 let (addr, chain) = result.unwrap();
2011 assert_eq!(addr, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
2012 assert_eq!(chain, "ethereum");
2013 }
2014
2015 #[test]
2016 fn test_resolve_address_book_input_by_address() {
2017 let tmp_dir = TempDir::new().unwrap();
2018 let mut address_book = AddressBook::default();
2019 address_book
2020 .add_address(WatchedAddress {
2021 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
2022 label: Some("test".to_string()),
2023 chain: "polygon".to_string(),
2024 tags: vec![],
2025 added_at: 0,
2026 })
2027 .unwrap();
2028 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
2029
2030 let config = Config {
2031 address_book: crate::config::AddressBookConfig {
2032 data_dir: Some(tmp_dir.path().to_path_buf()),
2033 },
2034 ..Default::default()
2035 };
2036
2037 let result =
2039 resolve_address_book_input("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", &config)
2040 .unwrap();
2041 assert!(result.is_some());
2042 let (_addr, chain) = result.unwrap();
2043 assert_eq!(chain, "polygon");
2044 }
2045
2046 #[test]
2047 fn test_resolve_address_book_input_not_found() {
2048 let tmp_dir = TempDir::new().unwrap();
2049 let config = Config {
2050 address_book: crate::config::AddressBookConfig {
2051 data_dir: Some(tmp_dir.path().to_path_buf()),
2052 },
2053 ..Default::default()
2054 };
2055
2056 let result = resolve_address_book_input("@unknown-label", &config);
2057 assert!(result.is_err());
2058 }
2059
2060 #[test]
2061 fn test_resolve_address_book_input_empty_address_book() {
2062 let tmp_dir = TempDir::new().unwrap();
2063 let config = Config {
2064 address_book: crate::config::AddressBookConfig {
2065 data_dir: Some(tmp_dir.path().to_path_buf()),
2066 },
2067 ..Default::default()
2068 };
2069
2070 let result = resolve_address_book_input("@anything", &config);
2071 assert!(result.is_err());
2072 }
2073
2074 #[test]
2075 fn test_resolve_address_book_input_label_not_found_with_available_labels() {
2076 let tmp_dir = TempDir::new().unwrap();
2077 let mut address_book = AddressBook::default();
2078 address_book
2079 .add_address(WatchedAddress {
2080 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
2081 label: Some("main-wallet".to_string()),
2082 chain: "ethereum".to_string(),
2083 tags: vec![],
2084 added_at: 0,
2085 })
2086 .unwrap();
2087 address_book
2088 .add_address(WatchedAddress {
2089 address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
2090 label: Some("trading".to_string()),
2091 chain: "polygon".to_string(),
2092 tags: vec![],
2093 added_at: 0,
2094 })
2095 .unwrap();
2096 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
2097
2098 let config = Config {
2099 address_book: crate::config::AddressBookConfig {
2100 data_dir: Some(tmp_dir.path().to_path_buf()),
2101 },
2102 ..Default::default()
2103 };
2104
2105 let result = resolve_address_book_input("@nonexistent-label", &config);
2106 assert!(result.is_err());
2107 let err_msg = result.unwrap_err().to_string();
2108 assert!(err_msg.contains("No address book entry matching '@nonexistent-label'"));
2109 assert!(err_msg.contains("Available labels"));
2110 assert!(err_msg.contains("@main-wallet"));
2111 assert!(err_msg.contains("@trading"));
2112 }
2113
2114 #[test]
2115 fn test_resolve_address_book_input_case_insensitive_label() {
2116 let tmp_dir = TempDir::new().unwrap();
2117 let mut address_book = AddressBook::default();
2118 address_book
2119 .add_address(WatchedAddress {
2120 address: "0xABCDEF1234567890abcdef1234567890ABCDEF12".to_string(),
2121 label: Some("My DeFi Wallet".to_string()),
2122 chain: "arbitrum".to_string(),
2123 tags: vec![],
2124 added_at: 0,
2125 })
2126 .unwrap();
2127 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
2128
2129 let config = Config {
2130 address_book: crate::config::AddressBookConfig {
2131 data_dir: Some(tmp_dir.path().to_path_buf()),
2132 },
2133 ..Default::default()
2134 };
2135
2136 let result = resolve_address_book_input("@my defi wallet", &config).unwrap();
2137 assert!(result.is_some());
2138 let (addr, chain) = result.unwrap();
2139 assert_eq!(addr, "0xABCDEF1234567890abcdef1234567890ABCDEF12");
2140 assert_eq!(chain, "arbitrum");
2141 }
2142
2143 #[test]
2144 fn test_resolve_address_book_input_raw_address_not_in_book_returns_none() {
2145 let tmp_dir = TempDir::new().unwrap();
2146 let mut address_book = AddressBook::default();
2147 address_book
2148 .add_address(WatchedAddress {
2149 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
2150 label: Some("test".to_string()),
2151 chain: "ethereum".to_string(),
2152 tags: vec![],
2153 added_at: 0,
2154 })
2155 .unwrap();
2156 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
2157
2158 let config = Config {
2159 address_book: crate::config::AddressBookConfig {
2160 data_dir: Some(tmp_dir.path().to_path_buf()),
2161 },
2162 ..Default::default()
2163 };
2164
2165 let result =
2167 resolve_address_book_input("0xnonexistent123456789012345678901234567890", &config)
2168 .unwrap();
2169 assert!(result.is_none());
2170 }
2171
2172 #[test]
2173 fn test_resolve_address_book_input_load_fails_returns_none() {
2174 let tmp_dir = TempDir::new().unwrap();
2175 let address_book_path = tmp_dir.path().join("address_book.yaml");
2176 std::fs::create_dir_all(tmp_dir.path()).unwrap();
2177 std::fs::write(&address_book_path, "invalid: yaml: content: [").unwrap();
2178
2179 let config = Config {
2180 address_book: crate::config::AddressBookConfig {
2181 data_dir: Some(tmp_dir.path().to_path_buf()),
2182 },
2183 ..Default::default()
2184 };
2185
2186 let result =
2188 resolve_address_book_input("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", &config);
2189 assert!(result.is_ok());
2190 assert!(result.unwrap().is_none());
2191 }
2192
2193 #[test]
2194 fn test_address_book_remove_address_case_insensitive() {
2195 let mut address_book = create_test_address_book();
2196 let original_len = address_book.addresses.len();
2197
2198 let removed = address_book
2199 .remove_address("0x742D35CC6634C0532925A3B844BC9E7595F1B3C2")
2200 .unwrap();
2201
2202 assert!(removed);
2203 assert_eq!(address_book.addresses.len(), original_len - 1);
2204 }
2205
2206 #[test]
2207 fn test_address_book_remove_args_parsing() {
2208 use clap::Parser;
2209
2210 #[derive(Parser)]
2211 struct TestCli {
2212 #[command(flatten)]
2213 args: AddressBookArgs,
2214 }
2215
2216 let cli = TestCli::try_parse_from([
2217 "test",
2218 "remove",
2219 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
2220 ])
2221 .unwrap();
2222
2223 if let AddressBookCommands::Remove(remove_args) = cli.args.command {
2224 assert_eq!(
2225 remove_args.address,
2226 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
2227 );
2228 } else {
2229 panic!("Expected Remove command");
2230 }
2231 }
2232
2233 #[test]
2234 fn test_address_book_summary_args_parsing() {
2235 use clap::Parser;
2236
2237 #[derive(Parser)]
2238 struct TestCli {
2239 #[command(flatten)]
2240 args: AddressBookArgs,
2241 }
2242
2243 let cli = TestCli::try_parse_from([
2244 "test",
2245 "summary",
2246 "--chain",
2247 "ethereum",
2248 "--tag",
2249 "defi",
2250 "--include-tokens",
2251 "--report",
2252 "report.md",
2253 ])
2254 .unwrap();
2255
2256 if let AddressBookCommands::Summary(summary_args) = cli.args.command {
2257 assert_eq!(summary_args.chain, Some("ethereum".to_string()));
2258 assert_eq!(summary_args.tag, Some("defi".to_string()));
2259 assert!(summary_args.include_tokens);
2260 assert_eq!(
2261 summary_args.report,
2262 Some(std::path::PathBuf::from("report.md"))
2263 );
2264 } else {
2265 panic!("Expected Summary command");
2266 }
2267 }
2268
2269 #[test]
2270 fn test_token_summary_serialization() {
2271 let token = TokenSummary {
2272 contract_address: "0xToken123".to_string(),
2273 balance: "100.5".to_string(),
2274 decimals: 18,
2275 symbol: Some("USDC".to_string()),
2276 };
2277
2278 let json = serde_json::to_string(&token).unwrap();
2279 assert!(json.contains("0xToken123"));
2280 assert!(json.contains("100.5"));
2281 assert!(json.contains("USDC"));
2282
2283 let token_no_symbol = TokenSummary {
2284 contract_address: "0xNoSymbol".to_string(),
2285 balance: "50.0".to_string(),
2286 decimals: 6,
2287 symbol: None,
2288 };
2289 let json2 = serde_json::to_string(&token_no_symbol).unwrap();
2290 assert!(!json2.contains("symbol"));
2291 }
2292
2293 #[test]
2294 fn test_chain_balance_serialization_without_usd() {
2295 let balance = ChainBalance {
2296 native_balance: "10.5".to_string(),
2297 symbol: "ETH".to_string(),
2298 usd: None,
2299 };
2300
2301 let json = serde_json::to_string(&balance).unwrap();
2302 assert!(json.contains("10.5"));
2303 assert!(json.contains("ETH"));
2304 assert!(!json.contains("usd"));
2305 }
2306
2307 #[test]
2308 fn test_address_book_summary_to_markdown_empty_tokens_display() {
2309 let summary = AddressBookSummary {
2310 address_count: 1,
2311 balances_by_chain: HashMap::new(),
2312 total_usd: None,
2313 addresses: vec![AddressSummary {
2314 address: "0xEmptyTokens".to_string(),
2315 label: None,
2316 chain: "ethereum".to_string(),
2317 balance: "1.0".to_string(),
2318 usd: None,
2319 tokens: vec![],
2320 }],
2321 };
2322
2323 let md = address_book_summary_to_markdown(&summary);
2324 assert!(md.contains("0xEmptyTokens"));
2325 assert!(md.contains("| Address | Label | Chain | Balance | USD | Tokens |"));
2326 assert!(md.contains("-"));
2327 }
2328
2329 #[test]
2330 fn test_address_book_summary_to_markdown_token_without_symbol_uses_contract() {
2331 let mut balances = HashMap::new();
2332 balances.insert(
2333 "ethereum".to_string(),
2334 ChainBalance {
2335 native_balance: "1.0".to_string(),
2336 symbol: "ETH".to_string(),
2337 usd: None,
2338 },
2339 );
2340
2341 let summary = AddressBookSummary {
2342 address_count: 1,
2343 balances_by_chain: balances,
2344 total_usd: None,
2345 addresses: vec![AddressSummary {
2346 address: "0xAddr".to_string(),
2347 label: None,
2348 chain: "ethereum".to_string(),
2349 balance: "1.0".to_string(),
2350 usd: None,
2351 tokens: vec![TokenSummary {
2352 contract_address: "0xUnknownToken12345678".to_string(),
2353 balance: "100".to_string(),
2354 decimals: 18,
2355 symbol: None,
2356 }],
2357 }],
2358 };
2359
2360 let md = address_book_summary_to_markdown(&summary);
2361 assert!(md.contains("0xUnknownToken12345678"));
2362 }
2363
2364 #[test]
2365 fn test_address_book_load_invalid_yaml_returns_error() {
2366 let temp_dir = tempfile::tempdir().unwrap();
2367 let invalid_path = temp_dir.path().join("address_book.yaml");
2368 std::fs::write(&invalid_path, "not valid: yaml: [unclosed").unwrap();
2369
2370 let result = AddressBook::load(temp_dir.path());
2371 assert!(result.is_err());
2372 }
2373
2374 #[test]
2375 fn test_address_book_save_creates_directory() {
2376 let temp_dir = tempfile::tempdir().unwrap();
2377 let nested_dir = temp_dir.path().join("scope").join("nested");
2378 let address_book = create_test_address_book();
2379
2380 let result = address_book.save(&nested_dir);
2381 assert!(result.is_ok());
2382 assert!(nested_dir.exists());
2383 assert!(nested_dir.join("address_book.yaml").exists());
2384 }
2385
2386 #[tokio::test]
2387 async fn test_run_address_book_add_without_label() {
2388 let tmp_dir = tempfile::tempdir().unwrap();
2389 let config = Config {
2390 address_book: crate::config::AddressBookConfig {
2391 data_dir: Some(tmp_dir.path().to_path_buf()),
2392 },
2393 ..Default::default()
2394 };
2395 let factory = mock_factory();
2396
2397 let add_args = AddressBookArgs {
2398 command: AddressBookCommands::Add(AddArgs {
2399 address: "0xNoLabel".to_string(),
2400 label: None,
2401 chain: "ethereum".to_string(),
2402 tags: vec![],
2403 }),
2404 format: None,
2405 };
2406 let result = super::run(add_args, &config, &factory).await;
2407 assert!(result.is_ok());
2408 }
2409
2410 #[tokio::test]
2411 async fn test_run_address_book_remove_nonexistent_address() {
2412 let tmp_dir = tempfile::tempdir().unwrap();
2413 let config = Config {
2414 address_book: crate::config::AddressBookConfig {
2415 data_dir: Some(tmp_dir.path().to_path_buf()),
2416 },
2417 ..Default::default()
2418 };
2419 let factory = mock_factory();
2420
2421 let remove_args = AddressBookArgs {
2422 command: AddressBookCommands::Remove(RemoveArgs {
2423 address: "0xNeverAdded".to_string(),
2424 }),
2425 format: None,
2426 };
2427 let result = super::run(remove_args, &config, &factory).await;
2428 assert!(result.is_ok());
2429 }
2430
2431 #[tokio::test]
2432 async fn test_run_address_book_list_markdown_format() {
2433 let tmp_dir = tempfile::tempdir().unwrap();
2434 let config = Config {
2435 address_book: crate::config::AddressBookConfig {
2436 data_dir: Some(tmp_dir.path().to_path_buf()),
2437 },
2438 ..Default::default()
2439 };
2440 let factory = mock_factory();
2441
2442 let add_args = AddressBookArgs {
2443 command: AddressBookCommands::Add(AddArgs {
2444 address: "0xMdTest".to_string(),
2445 label: Some("MarkdownAddr".to_string()),
2446 chain: "ethereum".to_string(),
2447 tags: vec!["test".to_string()],
2448 }),
2449 format: None,
2450 };
2451 super::run(add_args, &config, &factory).await.unwrap();
2452
2453 let list_args = AddressBookArgs {
2454 command: AddressBookCommands::List,
2455 format: Some(OutputFormat::Markdown),
2456 };
2457 let result = super::run(list_args, &config, &factory).await;
2458 assert!(result.is_ok());
2459 }
2460
2461 #[tokio::test]
2462 async fn test_run_address_book_summary_markdown_format() {
2463 let tmp_dir = tempfile::tempdir().unwrap();
2464 let config = Config {
2465 address_book: crate::config::AddressBookConfig {
2466 data_dir: Some(tmp_dir.path().to_path_buf()),
2467 },
2468 ..Default::default()
2469 };
2470 let factory = mock_factory();
2471
2472 let add_args = AddressBookArgs {
2473 command: AddressBookCommands::Add(AddArgs {
2474 address: "0xSummaryMd".to_string(),
2475 label: Some("SummaryMarkdown".to_string()),
2476 chain: "ethereum".to_string(),
2477 tags: vec![],
2478 }),
2479 format: None,
2480 };
2481 super::run(add_args, &config, &factory).await.unwrap();
2482
2483 let summary_args = AddressBookArgs {
2484 command: AddressBookCommands::Summary(SummaryArgs {
2485 chain: None,
2486 tag: None,
2487 include_tokens: false,
2488 report: None,
2489 }),
2490 format: Some(OutputFormat::Markdown),
2491 };
2492 let result = super::run(summary_args, &config, &factory).await;
2493 assert!(result.is_ok());
2494 }
2495
2496 #[tokio::test]
2497 async fn test_run_address_book_summary_with_report_file() {
2498 let tmp_dir = tempfile::tempdir().unwrap();
2499 let report_path = tmp_dir.path().join("portfolio_report.md");
2500 let config = Config {
2501 address_book: crate::config::AddressBookConfig {
2502 data_dir: Some(tmp_dir.path().to_path_buf()),
2503 },
2504 ..Default::default()
2505 };
2506 let factory = mock_factory();
2507
2508 let add_args = AddressBookArgs {
2509 command: AddressBookCommands::Add(AddArgs {
2510 address: "0xReportTest".to_string(),
2511 label: Some("ReportAddr".to_string()),
2512 chain: "ethereum".to_string(),
2513 tags: vec![],
2514 }),
2515 format: None,
2516 };
2517 super::run(add_args, &config, &factory).await.unwrap();
2518
2519 let summary_args = AddressBookArgs {
2520 command: AddressBookCommands::Summary(SummaryArgs {
2521 chain: None,
2522 tag: None,
2523 include_tokens: false,
2524 report: Some(report_path.clone()),
2525 }),
2526 format: Some(OutputFormat::Table),
2527 };
2528 let result = super::run(summary_args, &config, &factory).await;
2529 assert!(result.is_ok());
2530 assert!(report_path.exists());
2531 let content = std::fs::read_to_string(&report_path).unwrap();
2532 assert!(content.contains("# Address Book Report"));
2533 assert!(content.contains("Report generated by Scope"));
2534 }
2535
2536 #[tokio::test]
2537 async fn test_run_address_book_summary_with_unsupported_chain() {
2538 let tmp_dir = tempfile::tempdir().unwrap();
2539 let config = Config {
2540 address_book: crate::config::AddressBookConfig {
2541 data_dir: Some(tmp_dir.path().to_path_buf()),
2542 },
2543 ..Default::default()
2544 };
2545
2546 let add_args = AddressBookArgs {
2547 command: AddressBookCommands::Add(AddArgs {
2548 address: "0xUnsupported".to_string(),
2549 label: None,
2550 chain: "unsupported_chain_xyz".to_string(),
2551 tags: vec![],
2552 }),
2553 format: None,
2554 };
2555 super::run(add_args, &config, &mock_factory())
2556 .await
2557 .unwrap();
2558
2559 let failing_factory = FailingChainClientFactory;
2560 let summary_args = AddressBookArgs {
2561 command: AddressBookCommands::Summary(SummaryArgs {
2562 chain: None,
2563 tag: None,
2564 include_tokens: false,
2565 report: None,
2566 }),
2567 format: Some(OutputFormat::Json),
2568 };
2569 let result = super::run(summary_args, &config, &failing_factory).await;
2570 assert!(result.is_ok());
2571 }
2572
2573 #[tokio::test]
2574 async fn test_run_address_book_format_override_from_args() {
2575 let tmp_dir = tempfile::tempdir().unwrap();
2576 let config = Config {
2577 address_book: crate::config::AddressBookConfig {
2578 data_dir: Some(tmp_dir.path().to_path_buf()),
2579 },
2580 ..Default::default()
2581 };
2582 let factory = mock_factory();
2583
2584 let add_args = AddressBookArgs {
2585 command: AddressBookCommands::Add(AddArgs {
2586 address: "0xFormatOverride".to_string(),
2587 label: None,
2588 chain: "ethereum".to_string(),
2589 tags: vec![],
2590 }),
2591 format: Some(OutputFormat::Json),
2592 };
2593 super::run(add_args, &config, &factory).await.unwrap();
2594
2595 let list_args = AddressBookArgs {
2596 command: AddressBookCommands::List,
2597 format: Some(OutputFormat::Json),
2598 };
2599 let result = super::run(list_args, &config, &factory).await;
2600 assert!(result.is_ok());
2601 }
2602}