Skip to main content

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