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