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