Skip to main content

scope_cli/cli/
address_book.rs

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