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