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 http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
650 scope::http::test_transport()
651 }
652 fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>> {
653 Err(ScopeError::Chain(format!("unsupported chain: {}", chain)))
654 }
655
656 fn create_dex_client(&self) -> Box<dyn DexDataSource> {
657 Box::new(MockDexSource::new())
658 }
659 }
660
661 #[tokio::test]
662 async fn test_run_address_book_list_empty() {
663 let tmp_dir = tempfile::tempdir().unwrap();
664 let config = Config {
665 address_book: scope::config::AddressBookConfig {
666 data_dir: Some(tmp_dir.path().to_path_buf()),
667 },
668 ..Default::default()
669 };
670 let factory = mock_factory();
671 let args = AddressBookArgs {
672 command: AddressBookCommands::List,
673 format: Some(OutputFormat::Table),
674 };
675 let result = super::run(args, &config, &factory, &quiet()).await;
676 assert!(result.is_ok());
677 }
678
679 #[tokio::test]
680 async fn test_run_address_book_add_and_list() {
681 let tmp_dir = tempfile::tempdir().unwrap();
682 let config = Config {
683 address_book: scope::config::AddressBookConfig {
684 data_dir: Some(tmp_dir.path().to_path_buf()),
685 },
686 ..Default::default()
687 };
688 let factory = mock_factory();
689
690 let add_args = AddressBookArgs {
692 command: AddressBookCommands::Add(AddArgs {
693 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
694 label: Some("Test Wallet".to_string()),
695 chain: "ethereum".to_string(),
696 tags: vec!["test".to_string()],
697 }),
698 format: Some(OutputFormat::Table),
699 };
700 let result = super::run(add_args, &config, &factory, &quiet()).await;
701 assert!(result.is_ok());
702
703 let list_args = AddressBookArgs {
705 command: AddressBookCommands::List,
706 format: Some(OutputFormat::Json),
707 };
708 let result = super::run(list_args, &config, &factory, &quiet()).await;
709 assert!(result.is_ok());
710 }
711
712 #[tokio::test]
713 async fn test_run_address_book_summary_with_mock() {
714 let tmp_dir = tempfile::tempdir().unwrap();
715 let config = Config {
716 address_book: scope::config::AddressBookConfig {
717 data_dir: Some(tmp_dir.path().to_path_buf()),
718 },
719 ..Default::default()
720 };
721 let factory = mock_factory();
722
723 let add_args = AddressBookArgs {
725 command: AddressBookCommands::Add(AddArgs {
726 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
727 label: Some("Test".to_string()),
728 chain: "ethereum".to_string(),
729 tags: vec![],
730 }),
731 format: None,
732 };
733 super::run(add_args, &config, &factory, &quiet())
734 .await
735 .unwrap();
736
737 let summary_args = AddressBookArgs {
739 command: AddressBookCommands::Summary(SummaryArgs {
740 chain: None,
741 tag: None,
742 include_tokens: false,
743 report: None,
744 }),
745 format: Some(OutputFormat::Json),
746 };
747 let result = super::run(summary_args, &config, &factory, &quiet()).await;
748 assert!(result.is_ok());
749 }
750
751 #[tokio::test]
752 async fn test_run_address_book_remove() {
753 let tmp_dir = tempfile::tempdir().unwrap();
754 let config = Config {
755 address_book: scope::config::AddressBookConfig {
756 data_dir: Some(tmp_dir.path().to_path_buf()),
757 },
758 ..Default::default()
759 };
760 let factory = mock_factory();
761
762 let add_args = AddressBookArgs {
764 command: AddressBookCommands::Add(AddArgs {
765 address: "0xtest".to_string(),
766 label: None,
767 chain: "ethereum".to_string(),
768 tags: vec![],
769 }),
770 format: None,
771 };
772 super::run(add_args, &config, &factory, &quiet())
773 .await
774 .unwrap();
775
776 let remove_args = AddressBookArgs {
777 command: AddressBookCommands::Remove(RemoveArgs {
778 address: "0xtest".to_string(),
779 }),
780 format: None,
781 };
782 let result = super::run(remove_args, &config, &factory, &quiet()).await;
783 assert!(result.is_ok());
784 }
785
786 #[tokio::test]
787 async fn test_run_address_book_summary_csv() {
788 let tmp_dir = tempfile::tempdir().unwrap();
789 let config = Config {
790 address_book: scope::config::AddressBookConfig {
791 data_dir: Some(tmp_dir.path().to_path_buf()),
792 },
793 ..Default::default()
794 };
795 let factory = mock_factory();
796
797 let add_args = AddressBookArgs {
799 command: AddressBookCommands::Add(AddArgs {
800 address: "0xtest".to_string(),
801 label: Some("TestAddr".to_string()),
802 chain: "ethereum".to_string(),
803 tags: vec!["defi".to_string()],
804 }),
805 format: None,
806 };
807 super::run(add_args, &config, &factory, &quiet())
808 .await
809 .unwrap();
810
811 let summary_args = AddressBookArgs {
813 command: AddressBookCommands::Summary(SummaryArgs {
814 chain: None,
815 tag: None,
816 include_tokens: false,
817 report: None,
818 }),
819 format: Some(OutputFormat::Csv),
820 };
821 let result = super::run(summary_args, &config, &factory, &quiet()).await;
822 assert!(result.is_ok());
823 }
824
825 #[tokio::test]
826 async fn test_run_address_book_summary_table() {
827 let tmp_dir = tempfile::tempdir().unwrap();
828 let config = Config {
829 address_book: scope::config::AddressBookConfig {
830 data_dir: Some(tmp_dir.path().to_path_buf()),
831 },
832 ..Default::default()
833 };
834 let factory = mock_factory();
835
836 let add_args = AddressBookArgs {
838 command: AddressBookCommands::Add(AddArgs {
839 address: "0xtest".to_string(),
840 label: Some("TestAddr".to_string()),
841 chain: "ethereum".to_string(),
842 tags: vec![],
843 }),
844 format: None,
845 };
846 super::run(add_args, &config, &factory, &quiet())
847 .await
848 .unwrap();
849
850 let summary_args = AddressBookArgs {
852 command: AddressBookCommands::Summary(SummaryArgs {
853 chain: None,
854 tag: None,
855 include_tokens: true,
856 report: None,
857 }),
858 format: Some(OutputFormat::Table),
859 };
860 let result = super::run(summary_args, &config, &factory, &quiet()).await;
861 assert!(result.is_ok());
862 }
863
864 #[tokio::test]
865 async fn test_run_address_book_summary_with_chain_filter() {
866 let tmp_dir = tempfile::tempdir().unwrap();
867 let config = Config {
868 address_book: scope::config::AddressBookConfig {
869 data_dir: Some(tmp_dir.path().to_path_buf()),
870 },
871 ..Default::default()
872 };
873 let factory = mock_factory();
874
875 let add_eth = AddressBookArgs {
877 command: AddressBookCommands::Add(AddArgs {
878 address: "0xeth".to_string(),
879 label: None,
880 chain: "ethereum".to_string(),
881 tags: vec![],
882 }),
883 format: None,
884 };
885 super::run(add_eth, &config, &factory, &quiet())
886 .await
887 .unwrap();
888
889 let add_poly = AddressBookArgs {
890 command: AddressBookCommands::Add(AddArgs {
891 address: "0xpoly".to_string(),
892 label: None,
893 chain: "polygon".to_string(),
894 tags: vec![],
895 }),
896 format: None,
897 };
898 super::run(add_poly, &config, &factory, &quiet())
899 .await
900 .unwrap();
901
902 let summary_args = AddressBookArgs {
904 command: AddressBookCommands::Summary(SummaryArgs {
905 chain: Some("ethereum".to_string()),
906 tag: None,
907 include_tokens: false,
908 report: None,
909 }),
910 format: Some(OutputFormat::Json),
911 };
912 let result = super::run(summary_args, &config, &factory, &quiet()).await;
913 assert!(result.is_ok());
914 }
915
916 #[tokio::test]
917 async fn test_run_address_book_summary_with_tag_filter() {
918 let tmp_dir = tempfile::tempdir().unwrap();
919 let config = Config {
920 address_book: scope::config::AddressBookConfig {
921 data_dir: Some(tmp_dir.path().to_path_buf()),
922 },
923 ..Default::default()
924 };
925 let factory = mock_factory();
926
927 let add_args = AddressBookArgs {
929 command: AddressBookCommands::Add(AddArgs {
930 address: "0xdefi".to_string(),
931 label: None,
932 chain: "ethereum".to_string(),
933 tags: vec!["defi".to_string()],
934 }),
935 format: None,
936 };
937 super::run(add_args, &config, &factory, &quiet())
938 .await
939 .unwrap();
940
941 let summary_args = AddressBookArgs {
943 command: AddressBookCommands::Summary(SummaryArgs {
944 chain: None,
945 tag: Some("defi".to_string()),
946 include_tokens: false,
947 report: None,
948 }),
949 format: Some(OutputFormat::Json),
950 };
951 let result = super::run(summary_args, &config, &factory, &quiet()).await;
952 assert!(result.is_ok());
953 }
954
955 #[tokio::test]
956 async fn test_run_address_book_summary_no_format() {
957 let tmp_dir = tempfile::tempdir().unwrap();
958 let config = Config {
959 address_book: scope::config::AddressBookConfig {
960 data_dir: Some(tmp_dir.path().to_path_buf()),
961 },
962 ..Default::default()
963 };
964 let factory = mock_factory();
965
966 let add_args = AddressBookArgs {
967 command: AddressBookCommands::Add(AddArgs {
968 address: "0xtest".to_string(),
969 label: None,
970 chain: "ethereum".to_string(),
971 tags: vec![],
972 }),
973 format: None,
974 };
975 super::run(add_args, &config, &factory, &quiet())
976 .await
977 .unwrap();
978
979 let summary_args = AddressBookArgs {
980 command: AddressBookCommands::Summary(SummaryArgs {
981 chain: None,
982 tag: None,
983 include_tokens: false,
984 report: None,
985 }),
986 format: None, };
988 let result = super::run(summary_args, &config, &factory, &quiet()).await;
989 assert!(result.is_ok());
990 }
991
992 #[tokio::test]
993 async fn test_run_address_book_summary_empty() {
994 let tmp_dir = tempfile::tempdir().unwrap();
995 let config = Config {
996 address_book: scope::config::AddressBookConfig {
997 data_dir: Some(tmp_dir.path().to_path_buf()),
998 },
999 ..Default::default()
1000 };
1001 let factory = mock_factory();
1002
1003 let summary_args = AddressBookArgs {
1005 command: AddressBookCommands::Summary(SummaryArgs {
1006 chain: None,
1007 tag: None,
1008 include_tokens: false,
1009 report: None,
1010 }),
1011 format: Some(OutputFormat::Table),
1012 };
1013 let result = super::run(summary_args, &config, &factory, &quiet()).await;
1014 assert!(result.is_ok());
1015 }
1016
1017 #[tokio::test]
1018 async fn test_run_address_book_add_with_tags() {
1019 let tmp_dir = tempfile::tempdir().unwrap();
1020 let config = Config {
1021 address_book: scope::config::AddressBookConfig {
1022 data_dir: Some(tmp_dir.path().to_path_buf()),
1023 },
1024 ..Default::default()
1025 };
1026 let factory = mock_factory();
1027
1028 let add_args = AddressBookArgs {
1029 command: AddressBookCommands::Add(AddArgs {
1030 address: "0xtagged".to_string(),
1031 label: Some("Tagged".to_string()),
1032 chain: "ethereum".to_string(),
1033 tags: vec!["defi".to_string(), "whale".to_string()],
1034 }),
1035 format: None,
1036 };
1037 let result = super::run(add_args, &config, &factory, &quiet()).await;
1038 assert!(result.is_ok());
1039 }
1040
1041 #[test]
1042 fn test_get_native_symbol_polygon() {
1043 assert_eq!(native_symbol("polygon"), "MATIC");
1044 }
1045
1046 #[test]
1047 fn test_get_native_symbol_bsc() {
1048 assert_eq!(native_symbol("bsc"), "BNB");
1049 }
1050
1051 #[test]
1052 fn test_get_native_symbol_evm_l2s() {
1053 assert_eq!(native_symbol("arbitrum"), "ETH");
1054 assert_eq!(native_symbol("optimism"), "ETH");
1055 assert_eq!(native_symbol("base"), "ETH");
1056 }
1057
1058 #[tokio::test]
1059 async fn test_run_address_book_list_csv_format() {
1060 let tmp_dir = tempfile::tempdir().unwrap();
1061 let config = Config {
1062 address_book: scope::config::AddressBookConfig {
1063 data_dir: Some(tmp_dir.path().to_path_buf()),
1064 },
1065 ..Default::default()
1066 };
1067 let factory = mock_factory();
1068
1069 let add_args = AddressBookArgs {
1071 command: AddressBookCommands::Add(AddArgs {
1072 address: "0xCSV_test".to_string(),
1073 label: Some("CsvAddr".to_string()),
1074 chain: "ethereum".to_string(),
1075 tags: vec!["test".to_string()],
1076 }),
1077 format: None,
1078 };
1079 super::run(add_args, &config, &factory, &quiet())
1080 .await
1081 .unwrap();
1082
1083 let list_args = AddressBookArgs {
1085 command: AddressBookCommands::List,
1086 format: Some(OutputFormat::Csv),
1087 };
1088 let result = super::run(list_args, &config, &factory, &quiet()).await;
1089 assert!(result.is_ok());
1090 }
1091
1092 #[tokio::test]
1093 async fn test_run_address_book_list_table_format() {
1094 let tmp_dir = tempfile::tempdir().unwrap();
1095 let config = Config {
1096 address_book: scope::config::AddressBookConfig {
1097 data_dir: Some(tmp_dir.path().to_path_buf()),
1098 },
1099 ..Default::default()
1100 };
1101 let factory = mock_factory();
1102
1103 let add_args = AddressBookArgs {
1105 command: AddressBookCommands::Add(AddArgs {
1106 address: "0xTable_test1".to_string(),
1107 label: Some("LabeledAddr".to_string()),
1108 chain: "ethereum".to_string(),
1109 tags: vec!["personal".to_string(), "defi".to_string()],
1110 }),
1111 format: None,
1112 };
1113 super::run(add_args, &config, &factory, &quiet())
1114 .await
1115 .unwrap();
1116
1117 let add_args2 = AddressBookArgs {
1118 command: AddressBookCommands::Add(AddArgs {
1119 address: "0xTable_test2".to_string(),
1120 label: None,
1121 chain: "polygon".to_string(),
1122 tags: vec![],
1123 }),
1124 format: None,
1125 };
1126 super::run(add_args2, &config, &factory, &quiet())
1127 .await
1128 .unwrap();
1129
1130 let list_args = AddressBookArgs {
1132 command: AddressBookCommands::List,
1133 format: Some(OutputFormat::Table),
1134 };
1135 let result = super::run(list_args, &config, &factory, &quiet()).await;
1136 assert!(result.is_ok());
1137 }
1138
1139 #[tokio::test]
1140 async fn test_run_address_book_summary_table_with_tokens() {
1141 let tmp_dir = tempfile::tempdir().unwrap();
1142 let config = Config {
1143 address_book: scope::config::AddressBookConfig {
1144 data_dir: Some(tmp_dir.path().to_path_buf()),
1145 },
1146 ..Default::default()
1147 };
1148 let factory = mock_factory();
1149
1150 let add_args = AddressBookArgs {
1152 command: AddressBookCommands::Add(AddArgs {
1153 address: "0xTokenTest".to_string(),
1154 label: Some("TokenAddr".to_string()),
1155 chain: "ethereum".to_string(),
1156 tags: vec![],
1157 }),
1158 format: None,
1159 };
1160 super::run(add_args, &config, &factory, &quiet())
1161 .await
1162 .unwrap();
1163
1164 let summary_args = AddressBookArgs {
1166 command: AddressBookCommands::Summary(SummaryArgs {
1167 chain: None,
1168 tag: None,
1169 include_tokens: true,
1170 report: None,
1171 }),
1172 format: Some(OutputFormat::Table),
1173 };
1174 let result = super::run(summary_args, &config, &factory, &quiet()).await;
1175 assert!(result.is_ok());
1176 }
1177
1178 #[tokio::test]
1179 async fn test_run_address_book_summary_multiple_chains() {
1180 let tmp_dir = tempfile::tempdir().unwrap();
1181 let config = Config {
1182 address_book: scope::config::AddressBookConfig {
1183 data_dir: Some(tmp_dir.path().to_path_buf()),
1184 },
1185 ..Default::default()
1186 };
1187 let factory = mock_factory();
1188
1189 let add1 = AddressBookArgs {
1191 command: AddressBookCommands::Add(AddArgs {
1192 address: "0xMulti1".to_string(),
1193 label: None,
1194 chain: "ethereum".to_string(),
1195 tags: vec![],
1196 }),
1197 format: None,
1198 };
1199 super::run(add1, &config, &factory, &quiet()).await.unwrap();
1200
1201 let add2 = AddressBookArgs {
1202 command: AddressBookCommands::Add(AddArgs {
1203 address: "0xMulti2".to_string(),
1204 label: None,
1205 chain: "ethereum".to_string(),
1206 tags: vec![],
1207 }),
1208 format: None,
1209 };
1210 super::run(add2, &config, &factory, &quiet()).await.unwrap();
1211
1212 let summary_args = AddressBookArgs {
1214 command: AddressBookCommands::Summary(SummaryArgs {
1215 chain: None,
1216 tag: None,
1217 include_tokens: false,
1218 report: None,
1219 }),
1220 format: Some(OutputFormat::Table),
1221 };
1222 let result = super::run(summary_args, &config, &factory, &quiet()).await;
1223 assert!(result.is_ok());
1224 }
1225
1226 #[tokio::test]
1227 async fn test_run_address_book_list_no_format() {
1228 let tmp_dir = tempfile::tempdir().unwrap();
1229 let config = Config {
1230 address_book: scope::config::AddressBookConfig {
1231 data_dir: Some(tmp_dir.path().to_path_buf()),
1232 },
1233 ..Default::default()
1234 };
1235 let factory = mock_factory();
1236
1237 let add_args = AddressBookArgs {
1239 command: AddressBookCommands::Add(AddArgs {
1240 address: "0xNoFmt".to_string(),
1241 label: Some("Test".to_string()),
1242 chain: "ethereum".to_string(),
1243 tags: vec![],
1244 }),
1245 format: None,
1246 };
1247 super::run(add_args, &config, &factory, &quiet())
1248 .await
1249 .unwrap();
1250
1251 let list_args = AddressBookArgs {
1253 command: AddressBookCommands::List,
1254 format: None,
1255 };
1256 let result = super::run(list_args, &config, &factory, &quiet()).await;
1257 assert!(result.is_ok());
1258 }
1259
1260 #[test]
1261 fn test_address_book_new() {
1262 let p = AddressBook::default();
1263 assert!(p.addresses.is_empty());
1264 }
1265
1266 #[test]
1267 fn test_address_book_load_missing_dir() {
1268 let temp = tempfile::tempdir().unwrap();
1269 let p = AddressBook::load(temp.path());
1270 assert!(p.is_ok());
1271 assert!(p.unwrap().addresses.is_empty());
1272 }
1273
1274 #[test]
1275 fn test_address_book_add_and_save_roundtrip() {
1276 let temp = tempfile::tempdir().unwrap();
1277 let mut p = AddressBook::default();
1278 let addr = WatchedAddress {
1279 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1280 label: Some("Test".to_string()),
1281 chain: "ethereum".to_string(),
1282 tags: vec!["tag1".to_string()],
1283 added_at: 1234567890,
1284 };
1285 p.add_address(addr).unwrap();
1286 assert_eq!(p.addresses.len(), 1);
1287
1288 let data_dir = temp.path().to_path_buf();
1289 p.save(&data_dir).unwrap();
1290 let loaded = AddressBook::load(temp.path()).unwrap();
1291 assert_eq!(loaded.addresses.len(), 1);
1292 assert_eq!(
1293 loaded.addresses[0].address,
1294 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1295 );
1296 assert_eq!(loaded.addresses[0].label, Some("Test".to_string()));
1297 }
1298
1299 #[test]
1300 fn test_address_book_add_duplicate() {
1301 let mut p = AddressBook::default();
1302 let addr1 = WatchedAddress {
1303 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1304 label: None,
1305 chain: "ethereum".to_string(),
1306 tags: vec![],
1307 added_at: 0,
1308 };
1309 let addr2 = WatchedAddress {
1310 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1311 label: None,
1312 chain: "ethereum".to_string(),
1313 tags: vec![],
1314 added_at: 0,
1315 };
1316 p.add_address(addr1).unwrap();
1317 let result = p.add_address(addr2);
1318 assert!(result.is_err());
1320 assert!(
1321 result
1322 .unwrap_err()
1323 .to_string()
1324 .contains("already in address book")
1325 );
1326 }
1327
1328 #[test]
1329 fn test_watched_address_debug() {
1330 let addr = WatchedAddress {
1331 address: "0xtest".to_string(),
1332 label: Some("My Wallet".to_string()),
1333 chain: "ethereum".to_string(),
1334 tags: vec!["defi".to_string(), "staking".to_string()],
1335 added_at: 1700000000,
1336 };
1337 let debug = format!("{:?}", addr);
1338 assert!(debug.contains("WatchedAddress"));
1339 assert!(debug.contains("0xtest"));
1340 }
1341
1342 #[test]
1347 fn test_address_book_summary_to_markdown_basic() {
1348 let mut balances_by_chain = HashMap::new();
1349 balances_by_chain.insert(
1350 "ethereum".to_string(),
1351 ChainBalance {
1352 native_balance: "1.5".to_string(),
1353 symbol: "ETH".to_string(),
1354 usd: None,
1355 },
1356 );
1357
1358 let summary = AddressBookSummary {
1359 address_count: 2,
1360 balances_by_chain,
1361 total_usd: None,
1362 addresses: vec![
1363 AddressSummary {
1364 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1365 label: Some("Main Wallet".to_string()),
1366 chain: "ethereum".to_string(),
1367 balance: "1.5".to_string(),
1368 usd: None,
1369 tokens: vec![],
1370 },
1371 AddressSummary {
1372 address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
1373 label: None,
1374 chain: "polygon".to_string(),
1375 balance: "100.0".to_string(),
1376 usd: None,
1377 tokens: vec![],
1378 },
1379 ],
1380 };
1381
1382 let md = address_book_summary_to_markdown(&summary);
1383
1384 assert!(md.contains("# Address Book Report"));
1386 assert!(md.contains("**Addresses:** 2"));
1387 assert!(md.contains("Allocation by Chain"));
1388 assert!(md.contains("## Addresses"));
1389
1390 assert!(md.contains("ethereum"));
1392 assert!(md.contains("1.5"));
1393 assert!(md.contains("ETH"));
1394
1395 assert!(md.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
1397 assert!(md.contains("Main Wallet"));
1398 assert!(md.contains("0xABCdef1234567890abcdef1234567890ABCDEF12"));
1399 assert!(md.contains("polygon"));
1400 assert!(md.contains("100.0"));
1401
1402 assert!(md.contains("Report generated by Scope"));
1404 }
1405
1406 #[test]
1407 fn test_address_book_summary_to_markdown_with_usd() {
1408 let mut balances_by_chain = HashMap::new();
1409 balances_by_chain.insert(
1410 "ethereum".to_string(),
1411 ChainBalance {
1412 native_balance: "2.0".to_string(),
1413 symbol: "ETH".to_string(),
1414 usd: Some(3000.0),
1415 },
1416 );
1417
1418 let summary = AddressBookSummary {
1419 address_count: 2,
1420 balances_by_chain,
1421 total_usd: Some(5000.0),
1422 addresses: vec![
1423 AddressSummary {
1424 address: "0x1234567890123456789012345678901234567890".to_string(),
1425 label: Some("Wallet 1".to_string()),
1426 chain: "ethereum".to_string(),
1427 balance: "2.0".to_string(),
1428 usd: Some(3000.0),
1429 tokens: vec![],
1430 },
1431 AddressSummary {
1432 address: "0x0987654321098765432109876543210987654321".to_string(),
1433 label: Some("Wallet 2".to_string()),
1434 chain: "ethereum".to_string(),
1435 balance: "1.0".to_string(),
1436 usd: Some(2000.0),
1437 tokens: vec![],
1438 },
1439 ],
1440 };
1441
1442 let md = address_book_summary_to_markdown(&summary);
1443
1444 assert!(md.contains("**Total Value (USD):** $5000.00"));
1446
1447 assert!(md.contains("$3000.00"));
1449
1450 assert!(md.contains("$3000.00"));
1452 assert!(md.contains("$2000.00"));
1453 }
1454
1455 #[test]
1456 fn test_address_book_summary_to_markdown_with_tokens() {
1457 let mut balances_by_chain = HashMap::new();
1458 balances_by_chain.insert(
1459 "ethereum".to_string(),
1460 ChainBalance {
1461 native_balance: "1.0".to_string(),
1462 symbol: "ETH".to_string(),
1463 usd: None,
1464 },
1465 );
1466
1467 let tokens = vec![
1469 TokenSummary {
1470 contract_address: "0xToken1".to_string(),
1471 balance: "100.0".to_string(),
1472 decimals: 18,
1473 symbol: Some("USDC".to_string()),
1474 },
1475 TokenSummary {
1476 contract_address: "0xToken2".to_string(),
1477 balance: "50.0".to_string(),
1478 decimals: 18,
1479 symbol: Some("DAI".to_string()),
1480 },
1481 TokenSummary {
1482 contract_address: "0xToken3".to_string(),
1483 balance: "25.0".to_string(),
1484 decimals: 18,
1485 symbol: Some("WBTC".to_string()),
1486 },
1487 TokenSummary {
1488 contract_address: "0xToken4".to_string(),
1489 balance: "10.0".to_string(),
1490 decimals: 18,
1491 symbol: Some("UNI".to_string()),
1492 },
1493 TokenSummary {
1494 contract_address: "0xToken5".to_string(),
1495 balance: "5.0".to_string(),
1496 decimals: 18,
1497 symbol: None, },
1499 ];
1500
1501 let summary = AddressBookSummary {
1502 address_count: 1,
1503 balances_by_chain,
1504 total_usd: None,
1505 addresses: vec![AddressSummary {
1506 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1507 label: Some("Token Wallet".to_string()),
1508 chain: "ethereum".to_string(),
1509 balance: "1.0".to_string(),
1510 usd: None,
1511 tokens,
1512 }],
1513 };
1514
1515 let md = address_book_summary_to_markdown(&summary);
1516
1517 assert!(md.contains("USDC"));
1519 assert!(md.contains("DAI"));
1520 assert!(md.contains("WBTC"));
1521
1522 assert!(md.contains("+2"));
1524
1525 }
1530
1531 #[test]
1532 fn test_address_book_summary_to_markdown_empty() {
1533 let summary = AddressBookSummary {
1534 address_count: 0,
1535 balances_by_chain: HashMap::new(),
1536 total_usd: None,
1537 addresses: vec![],
1538 };
1539
1540 let md = address_book_summary_to_markdown(&summary);
1541
1542 assert!(md.contains("# Address Book Report"));
1544 assert!(md.contains("**Addresses:** 0"));
1545
1546 assert!(md.contains("Allocation by Chain"));
1548
1549 assert!(md.contains("## Addresses"));
1551
1552 assert!(md.contains("Report generated by Scope"));
1554 }
1555
1556 #[test]
1561 fn test_find_by_label_exact_match() {
1562 let address_book = create_test_address_book();
1563 let found = address_book.find_by_label("Main Wallet");
1564 assert!(found.is_some());
1565 assert_eq!(
1566 found.unwrap().address,
1567 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1568 );
1569 }
1570
1571 #[test]
1572 fn test_find_by_label_case_insensitive() {
1573 let address_book = create_test_address_book();
1574 let found = address_book.find_by_label("main wallet");
1575 assert!(found.is_some());
1576 assert_eq!(
1577 found.unwrap().address,
1578 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1579 );
1580 }
1581
1582 #[test]
1583 fn test_find_by_label_with_whitespace() {
1584 let address_book = create_test_address_book();
1585 let found = address_book.find_by_label(" Main Wallet ");
1586 assert!(found.is_some());
1587 }
1588
1589 #[test]
1590 fn test_find_by_label_not_found() {
1591 let address_book = create_test_address_book();
1592 let found = address_book.find_by_label("nonexistent");
1593 assert!(found.is_none());
1594 }
1595
1596 #[test]
1597 fn test_find_by_label_no_label_entries() {
1598 let address_book = create_test_address_book();
1599 let found = address_book.find_by_label("");
1601 assert!(found.is_none());
1602 }
1603
1604 #[test]
1605 fn test_find_by_label_empty_address_book() {
1606 let address_book = AddressBook::default();
1607 let found = address_book.find_by_label("anything");
1608 assert!(found.is_none());
1609 }
1610
1611 #[test]
1616 fn test_resolve_address_book_input_by_label() {
1617 let tmp_dir = TempDir::new().unwrap();
1618 let mut address_book = AddressBook::default();
1619 address_book
1620 .add_address(WatchedAddress {
1621 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1622 label: Some("hot-wallet".to_string()),
1623 chain: "ethereum".to_string(),
1624 tags: vec![],
1625 added_at: 0,
1626 })
1627 .unwrap();
1628 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
1629
1630 let config = Config {
1631 address_book: scope::config::AddressBookConfig {
1632 data_dir: Some(tmp_dir.path().to_path_buf()),
1633 },
1634 ..Default::default()
1635 };
1636
1637 let result = resolve_address_book_input("@hot-wallet", &config).unwrap();
1638 assert!(result.is_some());
1639 let (addr, chain) = result.unwrap();
1640 assert_eq!(addr, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
1641 assert_eq!(chain, "ethereum");
1642 }
1643
1644 #[test]
1645 fn test_resolve_address_book_input_by_address() {
1646 let tmp_dir = TempDir::new().unwrap();
1647 let mut address_book = AddressBook::default();
1648 address_book
1649 .add_address(WatchedAddress {
1650 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1651 label: Some("test".to_string()),
1652 chain: "polygon".to_string(),
1653 tags: vec![],
1654 added_at: 0,
1655 })
1656 .unwrap();
1657 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
1658
1659 let config = Config {
1660 address_book: scope::config::AddressBookConfig {
1661 data_dir: Some(tmp_dir.path().to_path_buf()),
1662 },
1663 ..Default::default()
1664 };
1665
1666 let result =
1668 resolve_address_book_input("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", &config)
1669 .unwrap();
1670 assert!(result.is_some());
1671 let (_addr, chain) = result.unwrap();
1672 assert_eq!(chain, "polygon");
1673 }
1674
1675 #[test]
1676 fn test_resolve_address_book_input_not_found() {
1677 let tmp_dir = TempDir::new().unwrap();
1678 let config = Config {
1679 address_book: scope::config::AddressBookConfig {
1680 data_dir: Some(tmp_dir.path().to_path_buf()),
1681 },
1682 ..Default::default()
1683 };
1684
1685 let result = resolve_address_book_input("@unknown-label", &config);
1686 assert!(result.is_err());
1687 }
1688
1689 #[test]
1690 fn test_resolve_address_book_input_empty_address_book() {
1691 let tmp_dir = TempDir::new().unwrap();
1692 let config = Config {
1693 address_book: scope::config::AddressBookConfig {
1694 data_dir: Some(tmp_dir.path().to_path_buf()),
1695 },
1696 ..Default::default()
1697 };
1698
1699 let result = resolve_address_book_input("@anything", &config);
1700 assert!(result.is_err());
1701 }
1702
1703 #[test]
1704 fn test_resolve_address_book_input_label_not_found_with_available_labels() {
1705 let tmp_dir = TempDir::new().unwrap();
1706 let mut address_book = AddressBook::default();
1707 address_book
1708 .add_address(WatchedAddress {
1709 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1710 label: Some("main-wallet".to_string()),
1711 chain: "ethereum".to_string(),
1712 tags: vec![],
1713 added_at: 0,
1714 })
1715 .unwrap();
1716 address_book
1717 .add_address(WatchedAddress {
1718 address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
1719 label: Some("trading".to_string()),
1720 chain: "polygon".to_string(),
1721 tags: vec![],
1722 added_at: 0,
1723 })
1724 .unwrap();
1725 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
1726
1727 let config = Config {
1728 address_book: scope::config::AddressBookConfig {
1729 data_dir: Some(tmp_dir.path().to_path_buf()),
1730 },
1731 ..Default::default()
1732 };
1733
1734 let result = resolve_address_book_input("@nonexistent-label", &config);
1735 assert!(result.is_err());
1736 let err_msg = result.unwrap_err().to_string();
1737 assert!(err_msg.contains("No address book entry matching '@nonexistent-label'"));
1738 assert!(err_msg.contains("Available labels"));
1739 assert!(err_msg.contains("@main-wallet"));
1740 assert!(err_msg.contains("@trading"));
1741 }
1742
1743 #[test]
1744 fn test_resolve_address_book_input_case_insensitive_label() {
1745 let tmp_dir = TempDir::new().unwrap();
1746 let mut address_book = AddressBook::default();
1747 address_book
1748 .add_address(WatchedAddress {
1749 address: "0xABCDEF1234567890abcdef1234567890ABCDEF12".to_string(),
1750 label: Some("My DeFi Wallet".to_string()),
1751 chain: "arbitrum".to_string(),
1752 tags: vec![],
1753 added_at: 0,
1754 })
1755 .unwrap();
1756 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
1757
1758 let config = Config {
1759 address_book: scope::config::AddressBookConfig {
1760 data_dir: Some(tmp_dir.path().to_path_buf()),
1761 },
1762 ..Default::default()
1763 };
1764
1765 let result = resolve_address_book_input("@my defi wallet", &config).unwrap();
1766 assert!(result.is_some());
1767 let (addr, chain) = result.unwrap();
1768 assert_eq!(addr, "0xABCDEF1234567890abcdef1234567890ABCDEF12");
1769 assert_eq!(chain, "arbitrum");
1770 }
1771
1772 #[test]
1773 fn test_resolve_address_book_input_raw_address_not_in_book_returns_none() {
1774 let tmp_dir = TempDir::new().unwrap();
1775 let mut address_book = AddressBook::default();
1776 address_book
1777 .add_address(WatchedAddress {
1778 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1779 label: Some("test".to_string()),
1780 chain: "ethereum".to_string(),
1781 tags: vec![],
1782 added_at: 0,
1783 })
1784 .unwrap();
1785 address_book.save(&tmp_dir.path().to_path_buf()).unwrap();
1786
1787 let config = Config {
1788 address_book: scope::config::AddressBookConfig {
1789 data_dir: Some(tmp_dir.path().to_path_buf()),
1790 },
1791 ..Default::default()
1792 };
1793
1794 let result =
1796 resolve_address_book_input("0xnonexistent123456789012345678901234567890", &config)
1797 .unwrap();
1798 assert!(result.is_none());
1799 }
1800
1801 #[test]
1802 fn test_resolve_address_book_input_load_fails_returns_none() {
1803 let tmp_dir = TempDir::new().unwrap();
1804 let address_book_path = tmp_dir.path().join("address_book.yaml");
1805 std::fs::create_dir_all(tmp_dir.path()).unwrap();
1806 std::fs::write(&address_book_path, "invalid: yaml: content: [").unwrap();
1807
1808 let config = Config {
1809 address_book: scope::config::AddressBookConfig {
1810 data_dir: Some(tmp_dir.path().to_path_buf()),
1811 },
1812 ..Default::default()
1813 };
1814
1815 let result =
1817 resolve_address_book_input("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", &config);
1818 assert!(result.is_ok());
1819 assert!(result.unwrap().is_none());
1820 }
1821
1822 #[test]
1823 fn test_address_book_remove_address_case_insensitive() {
1824 let mut address_book = create_test_address_book();
1825 let original_len = address_book.addresses.len();
1826
1827 let removed = address_book
1828 .remove_address("0x742D35CC6634C0532925A3B844BC9E7595F1B3C2")
1829 .unwrap();
1830
1831 assert!(removed);
1832 assert_eq!(address_book.addresses.len(), original_len - 1);
1833 }
1834
1835 #[test]
1836 fn test_address_book_remove_args_parsing() {
1837 use clap::Parser;
1838
1839 #[derive(Parser)]
1840 struct TestCli {
1841 #[command(flatten)]
1842 args: AddressBookArgs,
1843 }
1844
1845 let cli = TestCli::try_parse_from([
1846 "test",
1847 "remove",
1848 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
1849 ])
1850 .unwrap();
1851
1852 if let AddressBookCommands::Remove(remove_args) = cli.args.command {
1853 assert_eq!(
1854 remove_args.address,
1855 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1856 );
1857 } else {
1858 panic!("Expected Remove command");
1859 }
1860 }
1861
1862 #[test]
1863 fn test_address_book_summary_args_parsing() {
1864 use clap::Parser;
1865
1866 #[derive(Parser)]
1867 struct TestCli {
1868 #[command(flatten)]
1869 args: AddressBookArgs,
1870 }
1871
1872 let cli = TestCli::try_parse_from([
1873 "test",
1874 "summary",
1875 "--chain",
1876 "ethereum",
1877 "--tag",
1878 "defi",
1879 "--include-tokens",
1880 "--report",
1881 "report.md",
1882 ])
1883 .unwrap();
1884
1885 if let AddressBookCommands::Summary(summary_args) = cli.args.command {
1886 assert_eq!(summary_args.chain, Some("ethereum".to_string()));
1887 assert_eq!(summary_args.tag, Some("defi".to_string()));
1888 assert!(summary_args.include_tokens);
1889 assert_eq!(
1890 summary_args.report,
1891 Some(std::path::PathBuf::from("report.md"))
1892 );
1893 } else {
1894 panic!("Expected Summary command");
1895 }
1896 }
1897
1898 #[test]
1899 fn test_token_summary_serialization() {
1900 let token = TokenSummary {
1901 contract_address: "0xToken123".to_string(),
1902 balance: "100.5".to_string(),
1903 decimals: 18,
1904 symbol: Some("USDC".to_string()),
1905 };
1906
1907 let json = serde_json::to_string(&token).unwrap();
1908 assert!(json.contains("0xToken123"));
1909 assert!(json.contains("100.5"));
1910 assert!(json.contains("USDC"));
1911
1912 let token_no_symbol = TokenSummary {
1913 contract_address: "0xNoSymbol".to_string(),
1914 balance: "50.0".to_string(),
1915 decimals: 6,
1916 symbol: None,
1917 };
1918 let json2 = serde_json::to_string(&token_no_symbol).unwrap();
1919 assert!(!json2.contains("symbol"));
1920 }
1921
1922 #[test]
1923 fn test_chain_balance_serialization_without_usd() {
1924 let balance = ChainBalance {
1925 native_balance: "10.5".to_string(),
1926 symbol: "ETH".to_string(),
1927 usd: None,
1928 };
1929
1930 let json = serde_json::to_string(&balance).unwrap();
1931 assert!(json.contains("10.5"));
1932 assert!(json.contains("ETH"));
1933 assert!(!json.contains("usd"));
1934 }
1935
1936 #[test]
1937 fn test_address_book_summary_to_markdown_empty_tokens_display() {
1938 let summary = AddressBookSummary {
1939 address_count: 1,
1940 balances_by_chain: HashMap::new(),
1941 total_usd: None,
1942 addresses: vec![AddressSummary {
1943 address: "0xEmptyTokens".to_string(),
1944 label: None,
1945 chain: "ethereum".to_string(),
1946 balance: "1.0".to_string(),
1947 usd: None,
1948 tokens: vec![],
1949 }],
1950 };
1951
1952 let md = address_book_summary_to_markdown(&summary);
1953 assert!(md.contains("0xEmptyTokens"));
1954 assert!(md.contains("| Address | Label | Chain | Balance | USD | Tokens |"));
1955 assert!(md.contains("-"));
1956 }
1957
1958 #[test]
1959 fn test_address_book_summary_to_markdown_token_without_symbol_uses_contract() {
1960 let mut balances = HashMap::new();
1961 balances.insert(
1962 "ethereum".to_string(),
1963 ChainBalance {
1964 native_balance: "1.0".to_string(),
1965 symbol: "ETH".to_string(),
1966 usd: None,
1967 },
1968 );
1969
1970 let summary = AddressBookSummary {
1971 address_count: 1,
1972 balances_by_chain: balances,
1973 total_usd: None,
1974 addresses: vec![AddressSummary {
1975 address: "0xAddr".to_string(),
1976 label: None,
1977 chain: "ethereum".to_string(),
1978 balance: "1.0".to_string(),
1979 usd: None,
1980 tokens: vec![TokenSummary {
1981 contract_address: "0xUnknownToken12345678".to_string(),
1982 balance: "100".to_string(),
1983 decimals: 18,
1984 symbol: None,
1985 }],
1986 }],
1987 };
1988
1989 let md = address_book_summary_to_markdown(&summary);
1990 assert!(md.contains("0xUnknownToken12345678"));
1991 }
1992
1993 #[test]
1994 fn test_address_book_load_invalid_yaml_returns_error() {
1995 let temp_dir = tempfile::tempdir().unwrap();
1996 let invalid_path = temp_dir.path().join("address_book.yaml");
1997 std::fs::write(&invalid_path, "not valid: yaml: [unclosed").unwrap();
1998
1999 let result = AddressBook::load(temp_dir.path());
2000 assert!(result.is_err());
2001 }
2002
2003 #[test]
2004 fn test_address_book_save_creates_directory() {
2005 let temp_dir = tempfile::tempdir().unwrap();
2006 let nested_dir = temp_dir.path().join("scope").join("nested");
2007 let address_book = create_test_address_book();
2008
2009 let result = address_book.save(&nested_dir);
2010 assert!(result.is_ok());
2011 assert!(nested_dir.exists());
2012 assert!(nested_dir.join("address_book.yaml").exists());
2013 }
2014
2015 #[tokio::test]
2016 async fn test_run_address_book_add_without_label() {
2017 let tmp_dir = tempfile::tempdir().unwrap();
2018 let config = Config {
2019 address_book: scope::config::AddressBookConfig {
2020 data_dir: Some(tmp_dir.path().to_path_buf()),
2021 },
2022 ..Default::default()
2023 };
2024 let factory = mock_factory();
2025
2026 let add_args = AddressBookArgs {
2027 command: AddressBookCommands::Add(AddArgs {
2028 address: "0xNoLabel".to_string(),
2029 label: None,
2030 chain: "ethereum".to_string(),
2031 tags: vec![],
2032 }),
2033 format: None,
2034 };
2035 let result = super::run(add_args, &config, &factory, &quiet()).await;
2036 assert!(result.is_ok());
2037 }
2038
2039 #[tokio::test]
2040 async fn test_run_address_book_remove_nonexistent_address() {
2041 let tmp_dir = tempfile::tempdir().unwrap();
2042 let config = Config {
2043 address_book: scope::config::AddressBookConfig {
2044 data_dir: Some(tmp_dir.path().to_path_buf()),
2045 },
2046 ..Default::default()
2047 };
2048 let factory = mock_factory();
2049
2050 let remove_args = AddressBookArgs {
2051 command: AddressBookCommands::Remove(RemoveArgs {
2052 address: "0xNeverAdded".to_string(),
2053 }),
2054 format: None,
2055 };
2056 let result = super::run(remove_args, &config, &factory, &quiet()).await;
2057 assert!(result.is_ok());
2058 }
2059
2060 #[tokio::test]
2061 async fn test_run_address_book_list_markdown_format() {
2062 let tmp_dir = tempfile::tempdir().unwrap();
2063 let config = Config {
2064 address_book: scope::config::AddressBookConfig {
2065 data_dir: Some(tmp_dir.path().to_path_buf()),
2066 },
2067 ..Default::default()
2068 };
2069 let factory = mock_factory();
2070
2071 let add_args = AddressBookArgs {
2072 command: AddressBookCommands::Add(AddArgs {
2073 address: "0xMdTest".to_string(),
2074 label: Some("MarkdownAddr".to_string()),
2075 chain: "ethereum".to_string(),
2076 tags: vec!["test".to_string()],
2077 }),
2078 format: None,
2079 };
2080 super::run(add_args, &config, &factory, &quiet())
2081 .await
2082 .unwrap();
2083
2084 let list_args = AddressBookArgs {
2085 command: AddressBookCommands::List,
2086 format: Some(OutputFormat::Markdown),
2087 };
2088 let result = super::run(list_args, &config, &factory, &quiet()).await;
2089 assert!(result.is_ok());
2090 }
2091
2092 #[tokio::test]
2093 async fn test_run_address_book_summary_markdown_format() {
2094 let tmp_dir = tempfile::tempdir().unwrap();
2095 let config = Config {
2096 address_book: scope::config::AddressBookConfig {
2097 data_dir: Some(tmp_dir.path().to_path_buf()),
2098 },
2099 ..Default::default()
2100 };
2101 let factory = mock_factory();
2102
2103 let add_args = AddressBookArgs {
2104 command: AddressBookCommands::Add(AddArgs {
2105 address: "0xSummaryMd".to_string(),
2106 label: Some("SummaryMarkdown".to_string()),
2107 chain: "ethereum".to_string(),
2108 tags: vec![],
2109 }),
2110 format: None,
2111 };
2112 super::run(add_args, &config, &factory, &quiet())
2113 .await
2114 .unwrap();
2115
2116 let summary_args = AddressBookArgs {
2117 command: AddressBookCommands::Summary(SummaryArgs {
2118 chain: None,
2119 tag: None,
2120 include_tokens: false,
2121 report: None,
2122 }),
2123 format: Some(OutputFormat::Markdown),
2124 };
2125 let result = super::run(summary_args, &config, &factory, &quiet()).await;
2126 assert!(result.is_ok());
2127 }
2128
2129 #[tokio::test]
2130 async fn test_run_address_book_summary_with_report_file() {
2131 let tmp_dir = tempfile::tempdir().unwrap();
2132 let report_path = tmp_dir.path().join("portfolio_report.md");
2133 let config = Config {
2134 address_book: scope::config::AddressBookConfig {
2135 data_dir: Some(tmp_dir.path().to_path_buf()),
2136 },
2137 ..Default::default()
2138 };
2139 let factory = mock_factory();
2140
2141 let add_args = AddressBookArgs {
2142 command: AddressBookCommands::Add(AddArgs {
2143 address: "0xReportTest".to_string(),
2144 label: Some("ReportAddr".to_string()),
2145 chain: "ethereum".to_string(),
2146 tags: vec![],
2147 }),
2148 format: None,
2149 };
2150 super::run(add_args, &config, &factory, &quiet())
2151 .await
2152 .unwrap();
2153
2154 let summary_args = AddressBookArgs {
2155 command: AddressBookCommands::Summary(SummaryArgs {
2156 chain: None,
2157 tag: None,
2158 include_tokens: false,
2159 report: Some(report_path.clone()),
2160 }),
2161 format: Some(OutputFormat::Table),
2162 };
2163 let result = super::run(summary_args, &config, &factory, &quiet()).await;
2164 assert!(result.is_ok());
2165 assert!(report_path.exists());
2166 let content = std::fs::read_to_string(&report_path).unwrap();
2167 assert!(content.contains("# Address Book Report"));
2168 assert!(content.contains("Report generated by Scope"));
2169 }
2170
2171 #[tokio::test]
2172 async fn test_run_address_book_summary_with_unsupported_chain() {
2173 let tmp_dir = tempfile::tempdir().unwrap();
2174 let config = Config {
2175 address_book: scope::config::AddressBookConfig {
2176 data_dir: Some(tmp_dir.path().to_path_buf()),
2177 },
2178 ..Default::default()
2179 };
2180
2181 let add_args = AddressBookArgs {
2182 command: AddressBookCommands::Add(AddArgs {
2183 address: "0xUnsupported".to_string(),
2184 label: None,
2185 chain: "unsupported_chain_xyz".to_string(),
2186 tags: vec![],
2187 }),
2188 format: None,
2189 };
2190 super::run(add_args, &config, &mock_factory(), &quiet())
2191 .await
2192 .unwrap();
2193
2194 let failing_factory = FailingChainClientFactory;
2195 let summary_args = AddressBookArgs {
2196 command: AddressBookCommands::Summary(SummaryArgs {
2197 chain: None,
2198 tag: None,
2199 include_tokens: false,
2200 report: None,
2201 }),
2202 format: Some(OutputFormat::Json),
2203 };
2204 let result = super::run(summary_args, &config, &failing_factory, &quiet()).await;
2205 assert!(result.is_ok());
2206 }
2207
2208 #[tokio::test]
2209 async fn test_run_address_book_format_override_from_args() {
2210 let tmp_dir = tempfile::tempdir().unwrap();
2211 let config = Config {
2212 address_book: scope::config::AddressBookConfig {
2213 data_dir: Some(tmp_dir.path().to_path_buf()),
2214 },
2215 ..Default::default()
2216 };
2217 let factory = mock_factory();
2218
2219 let add_args = AddressBookArgs {
2220 command: AddressBookCommands::Add(AddArgs {
2221 address: "0xFormatOverride".to_string(),
2222 label: None,
2223 chain: "ethereum".to_string(),
2224 tags: vec![],
2225 }),
2226 format: Some(OutputFormat::Json),
2227 };
2228 super::run(add_args, &config, &factory, &quiet())
2229 .await
2230 .unwrap();
2231
2232 let list_args = AddressBookArgs {
2233 command: AddressBookCommands::List,
2234 format: Some(OutputFormat::Json),
2235 };
2236 let result = super::run(list_args, &config, &factory, &quiet()).await;
2237 assert!(result.is_ok());
2238 }
2239}