Skip to main content

scope/cli/
portfolio.rs

1//! # Portfolio Management Command
2//!
3//! This module implements the `bca portfolio` command for managing
4//! watched addresses and viewing aggregated portfolio data.
5//!
6//! ## Usage
7//!
8//! ```bash
9//! # Add an address to portfolio
10//! bca portfolio add 0x742d... --label "Main Wallet"
11//!
12//! # List watched addresses
13//! bca portfolio list
14//!
15//! # Remove an address
16//! bca portfolio remove 0x742d...
17//!
18//! # View portfolio summary
19//! bca portfolio summary
20//! ```
21
22use crate::chains::ChainClientFactory;
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 portfolio management command.
31#[derive(Debug, Clone, Args)]
32pub struct PortfolioArgs {
33    /// Portfolio subcommand to execute.
34    #[command(subcommand)]
35    pub command: PortfolioCommands,
36
37    /// Override output format.
38    #[arg(short, long, global = true, value_name = "FORMAT")]
39    pub format: Option<OutputFormat>,
40}
41
42/// Portfolio subcommands.
43#[derive(Debug, Clone, Subcommand)]
44pub enum PortfolioCommands {
45    /// Add an address to the portfolio.
46    Add(AddArgs),
47
48    /// Remove an address from the portfolio.
49    Remove(RemoveArgs),
50
51    /// List all watched addresses.
52    List,
53
54    /// Show portfolio summary with balances.
55    Summary(SummaryArgs),
56}
57
58/// Arguments for adding an address.
59#[derive(Debug, Clone, Args)]
60pub struct AddArgs {
61    /// The address to add.
62    #[arg(value_name = "ADDRESS")]
63    pub address: String,
64
65    /// Human-readable label for the address.
66    #[arg(short, long)]
67    pub label: Option<String>,
68
69    /// Blockchain network for this address.
70    #[arg(short, long, default_value = "ethereum")]
71    pub chain: String,
72
73    /// Tags for categorization.
74    #[arg(short, long, value_delimiter = ',')]
75    pub tags: Vec<String>,
76}
77
78/// Arguments for removing an address.
79#[derive(Debug, Clone, Args)]
80pub struct RemoveArgs {
81    /// The address to remove.
82    #[arg(value_name = "ADDRESS")]
83    pub address: String,
84}
85
86/// Arguments for portfolio summary.
87#[derive(Debug, Clone, Args)]
88pub struct SummaryArgs {
89    /// Filter by chain.
90    #[arg(short, long)]
91    pub chain: Option<String>,
92
93    /// Filter by tag.
94    #[arg(short, long)]
95    pub tag: Option<String>,
96
97    /// Include token balances.
98    #[arg(long)]
99    pub include_tokens: bool,
100
101    /// Generate and save a markdown report to the specified path.
102    #[arg(long, value_name = "PATH")]
103    pub report: Option<std::path::PathBuf>,
104}
105
106/// A watched address in the portfolio.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct WatchedAddress {
109    /// The blockchain address.
110    pub address: String,
111
112    /// Human-readable label.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub label: Option<String>,
115
116    /// Blockchain network.
117    pub chain: String,
118
119    /// Tags for categorization.
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub tags: Vec<String>,
122
123    /// When the address was added (Unix timestamp).
124    pub added_at: u64,
125}
126
127/// Portfolio data storage.
128#[derive(Debug, Clone, Serialize, Deserialize, Default)]
129pub struct Portfolio {
130    /// All watched addresses.
131    pub addresses: Vec<WatchedAddress>,
132}
133
134/// Portfolio summary report.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct PortfolioSummary {
137    /// Total number of addresses.
138    pub address_count: usize,
139
140    /// Balances by chain.
141    pub balances_by_chain: HashMap<String, ChainBalance>,
142
143    /// Total portfolio value in USD (if available).
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub total_usd: Option<f64>,
146
147    /// Individual address summaries.
148    pub addresses: Vec<AddressSummary>,
149}
150
151/// Balance summary for a chain.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ChainBalance {
154    /// Native token balance.
155    pub native_balance: String,
156
157    /// Native token symbol.
158    pub symbol: String,
159
160    /// USD value (if available).
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub usd: Option<f64>,
163}
164
165/// Summary for a single address.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct AddressSummary {
168    /// The address.
169    pub address: String,
170
171    /// Label (if any).
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub label: Option<String>,
174
175    /// Chain.
176    pub chain: String,
177
178    /// Native balance.
179    pub balance: String,
180
181    /// USD value (if available).
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub usd: Option<f64>,
184
185    /// Token balances (for chains that support SPL/ERC20 tokens).
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub tokens: Vec<TokenSummary>,
188}
189
190/// Summary for a token balance.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct TokenSummary {
193    /// Token mint/contract address.
194    pub mint: String,
195    /// Token balance (human-readable).
196    pub balance: String,
197    /// Token decimals.
198    pub decimals: u8,
199    /// Token symbol (if known).
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub symbol: Option<String>,
202}
203
204impl Portfolio {
205    /// Loads the portfolio from the data directory.
206    pub fn load(data_dir: &std::path::Path) -> Result<Self> {
207        let path = data_dir.join("portfolio.yaml");
208
209        if !path.exists() {
210            return Ok(Self::default());
211        }
212
213        let contents = std::fs::read_to_string(&path)?;
214        let portfolio: Portfolio = serde_yaml::from_str(&contents)
215            .map_err(|e| ScopeError::Config(crate::error::ConfigError::Parse { source: e }))?;
216
217        Ok(portfolio)
218    }
219
220    /// Saves the portfolio to the data directory.
221    pub fn save(&self, data_dir: &PathBuf) -> Result<()> {
222        std::fs::create_dir_all(data_dir)?;
223
224        let path = data_dir.join("portfolio.yaml");
225        let contents = serde_yaml::to_string(self)
226            .map_err(|e| ScopeError::Export(format!("Failed to serialize portfolio: {}", e)))?;
227
228        std::fs::write(&path, contents)?;
229        Ok(())
230    }
231
232    /// Adds an address to the portfolio.
233    pub fn add_address(&mut self, watched: WatchedAddress) -> Result<()> {
234        // Check for duplicates
235        if self
236            .addresses
237            .iter()
238            .any(|a| a.address.to_lowercase() == watched.address.to_lowercase())
239        {
240            return Err(ScopeError::Chain(format!(
241                "Address already in portfolio: {}",
242                watched.address
243            )));
244        }
245
246        self.addresses.push(watched);
247        Ok(())
248    }
249
250    /// Removes an address from the portfolio.
251    pub fn remove_address(&mut self, address: &str) -> Result<bool> {
252        let original_len = self.addresses.len();
253        self.addresses
254            .retain(|a| a.address.to_lowercase() != address.to_lowercase());
255
256        Ok(self.addresses.len() < original_len)
257    }
258
259    /// Finds an address in the portfolio.
260    pub fn find_address(&self, address: &str) -> Option<&WatchedAddress> {
261        self.addresses
262            .iter()
263            .find(|a| a.address.to_lowercase() == address.to_lowercase())
264    }
265}
266
267/// Executes the portfolio command.
268pub async fn run(
269    args: PortfolioArgs,
270    config: &Config,
271    clients: &dyn ChainClientFactory,
272) -> Result<()> {
273    let data_dir = config.data_dir();
274    let format = args.format.unwrap_or(config.output.format);
275
276    match args.command {
277        PortfolioCommands::Add(add_args) => run_add(add_args, &data_dir).await,
278        PortfolioCommands::Remove(remove_args) => run_remove(remove_args, &data_dir).await,
279        PortfolioCommands::List => run_list(&data_dir, format).await,
280        PortfolioCommands::Summary(summary_args) => {
281            run_summary(summary_args, &data_dir, format, clients).await
282        }
283    }
284}
285
286async fn run_add(args: AddArgs, data_dir: &PathBuf) -> Result<()> {
287    tracing::info!(address = %args.address, "Adding address to portfolio");
288
289    let mut portfolio = Portfolio::load(data_dir)?;
290
291    let watched = WatchedAddress {
292        address: args.address.clone(),
293        label: args.label.clone(),
294        chain: args.chain.clone(),
295        tags: args.tags.clone(),
296        added_at: std::time::SystemTime::now()
297            .duration_since(std::time::UNIX_EPOCH)
298            .unwrap_or_default()
299            .as_secs(),
300    };
301
302    portfolio.add_address(watched)?;
303    portfolio.save(data_dir)?;
304
305    println!(
306        "Added {} to portfolio{}",
307        args.address,
308        args.label
309            .map(|l| format!(" as '{}'", l))
310            .unwrap_or_default()
311    );
312
313    Ok(())
314}
315
316async fn run_remove(args: RemoveArgs, data_dir: &PathBuf) -> Result<()> {
317    tracing::info!(address = %args.address, "Removing address from portfolio");
318
319    let mut portfolio = Portfolio::load(data_dir)?;
320    let removed = portfolio.remove_address(&args.address)?;
321
322    if removed {
323        portfolio.save(data_dir)?;
324        println!("Removed {} from portfolio", args.address);
325    } else {
326        println!("Address not found in portfolio: {}", args.address);
327    }
328
329    Ok(())
330}
331
332async fn run_list(data_dir: &std::path::Path, format: OutputFormat) -> Result<()> {
333    let portfolio = Portfolio::load(data_dir)?;
334
335    if portfolio.addresses.is_empty() {
336        println!("Portfolio is empty. Add addresses with 'bca portfolio add <address>'");
337        return Ok(());
338    }
339
340    match format {
341        OutputFormat::Json => {
342            let json = serde_json::to_string_pretty(&portfolio.addresses)?;
343            println!("{}", json);
344        }
345        OutputFormat::Csv => {
346            println!("address,label,chain,tags");
347            for addr in &portfolio.addresses {
348                println!(
349                    "{},{},{},{}",
350                    addr.address,
351                    addr.label.as_deref().unwrap_or(""),
352                    addr.chain,
353                    addr.tags.join(";")
354                );
355            }
356        }
357        OutputFormat::Table => {
358            println!("Portfolio Addresses");
359            println!("===================");
360            for addr in &portfolio.addresses {
361                println!(
362                    "  {} ({}) - {}{}",
363                    addr.address,
364                    addr.chain,
365                    addr.label.as_deref().unwrap_or("No label"),
366                    if addr.tags.is_empty() {
367                        String::new()
368                    } else {
369                        format!(" [{}]", addr.tags.join(", "))
370                    }
371                );
372            }
373            println!("\nTotal: {} addresses", portfolio.addresses.len());
374        }
375        OutputFormat::Markdown => {
376            let mut md = "# Portfolio Addresses\n\n".to_string();
377            md.push_str("| Address | Chain | Label | Tags |\n|---------|-------|-------|------|\n");
378            for addr in &portfolio.addresses {
379                let tags = if addr.tags.is_empty() {
380                    "-".to_string()
381                } else {
382                    addr.tags.join(", ")
383                };
384                md.push_str(&format!(
385                    "| `{}` | {} | {} | {} |\n",
386                    addr.address,
387                    addr.chain,
388                    addr.label.as_deref().unwrap_or("-"),
389                    tags
390                ));
391            }
392            md.push_str(&format!(
393                "\n**Total:** {} addresses\n",
394                portfolio.addresses.len()
395            ));
396            println!("{}", md);
397        }
398    }
399
400    Ok(())
401}
402
403async fn run_summary(
404    args: SummaryArgs,
405    data_dir: &std::path::Path,
406    format: OutputFormat,
407    clients: &dyn ChainClientFactory,
408) -> Result<()> {
409    let portfolio = Portfolio::load(data_dir)?;
410
411    if portfolio.addresses.is_empty() {
412        println!("Portfolio is empty. Add addresses with 'bca portfolio add <address>'");
413        return Ok(());
414    }
415
416    // Filter addresses
417    let filtered: Vec<_> = portfolio
418        .addresses
419        .iter()
420        .filter(|a| args.chain.as_ref().is_none_or(|c| &a.chain == c))
421        .filter(|a| args.tag.as_ref().is_none_or(|t| a.tags.contains(t)))
422        .collect();
423
424    // Fetch balances for each address
425    let mut address_summaries = Vec::new();
426    let mut balances_by_chain: HashMap<String, ChainBalance> = HashMap::new();
427
428    for watched in &filtered {
429        let (balance, tokens) = fetch_address_balance(
430            &watched.address,
431            &watched.chain,
432            clients,
433            args.include_tokens,
434        )
435        .await;
436
437        // Aggregate chain balances
438        if let Some(chain_bal) = balances_by_chain.get_mut(&watched.chain) {
439            // For simplicity, we're showing individual balances, not aggregating
440            // A more complete implementation would sum balances
441            let _ = chain_bal;
442        } else {
443            balances_by_chain.insert(
444                watched.chain.clone(),
445                ChainBalance {
446                    native_balance: balance.clone(),
447                    symbol: get_native_symbol(&watched.chain),
448                    usd: None,
449                },
450            );
451        }
452
453        address_summaries.push(AddressSummary {
454            address: watched.address.clone(),
455            label: watched.label.clone(),
456            chain: watched.chain.clone(),
457            balance,
458            usd: None,
459            tokens,
460        });
461    }
462
463    let summary = PortfolioSummary {
464        address_count: filtered.len(),
465        balances_by_chain,
466        total_usd: None,
467        addresses: address_summaries,
468    };
469
470    match format {
471        OutputFormat::Json => {
472            let json = serde_json::to_string_pretty(&summary)?;
473            println!("{}", json);
474        }
475        OutputFormat::Csv => {
476            println!("address,label,chain,balance,usd");
477            for addr in &summary.addresses {
478                println!(
479                    "{},{},{},{},{}",
480                    addr.address,
481                    addr.label.as_deref().unwrap_or(""),
482                    addr.chain,
483                    addr.balance,
484                    addr.usd.map_or(String::new(), |u| format!("{:.2}", u))
485                );
486            }
487        }
488        OutputFormat::Table => {
489            println!("Portfolio Summary");
490            println!("=================");
491            println!("Addresses: {}", summary.address_count);
492            println!();
493
494            for addr in &summary.addresses {
495                println!(
496                    "  {} ({}) - {} {}",
497                    addr.label.as_deref().unwrap_or(&addr.address),
498                    addr.chain,
499                    addr.balance,
500                    addr.usd.map_or(String::new(), |u| format!("(${:.2})", u))
501                );
502
503                // Show token balances
504                for token in &addr.tokens {
505                    let mint_short = if token.mint.len() >= 8 {
506                        &token.mint[..8]
507                    } else {
508                        &token.mint
509                    };
510                    let symbol = token.symbol.as_deref().unwrap_or(mint_short);
511                    println!("    └─ {} {}", token.balance, symbol);
512                }
513            }
514
515            if let Some(total) = summary.total_usd {
516                println!();
517                println!("Total Value: ${:.2}", total);
518            }
519        }
520        OutputFormat::Markdown => {
521            let md = portfolio_summary_to_markdown(&summary);
522            println!("{}", md);
523        }
524    }
525
526    // Generate report if requested
527    if let Some(ref report_path) = args.report {
528        let md = portfolio_summary_to_markdown(&summary);
529        std::fs::write(report_path, md)?;
530        println!("\nReport saved to: {}", report_path.display());
531    }
532
533    Ok(())
534}
535
536/// Generates a markdown report for portfolio summary.
537fn portfolio_summary_to_markdown(summary: &PortfolioSummary) -> String {
538    let mut md = format!(
539        "# Portfolio Report\n\n\
540        **Generated:** {}  \n\
541        **Addresses:** {}  \n\n",
542        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
543        summary.address_count
544    );
545
546    if let Some(total) = summary.total_usd {
547        md.push_str(&format!("**Total Value (USD):** ${:.2}  \n\n", total));
548    }
549
550    md.push_str("## Allocation by Chain\n\n");
551    md.push_str(
552        "| Chain | Native Balance | Symbol | USD |\n|-------|----------------|--------|-----|\n",
553    );
554    for (chain, bal) in &summary.balances_by_chain {
555        let usd = bal
556            .usd
557            .map(|u| format!("${:.2}", u))
558            .unwrap_or_else(|| "-".to_string());
559        md.push_str(&format!(
560            "| {} | {} | {} | {} |\n",
561            chain, bal.native_balance, bal.symbol, usd
562        ));
563    }
564
565    md.push_str("\n## Addresses\n\n");
566    md.push_str("| Address | Label | Chain | Balance | USD | Tokens |\n");
567    md.push_str("|---------|-------|-------|---------|-----|--------|\n");
568    for addr in &summary.addresses {
569        let label = addr.label.as_deref().unwrap_or("-");
570        let usd = addr
571            .usd
572            .map(|u| format!("${:.2}", u))
573            .unwrap_or_else(|| "-".to_string());
574        let token_list: String = addr
575            .tokens
576            .iter()
577            .map(|t| t.symbol.as_deref().unwrap_or(&t.mint))
578            .take(3)
579            .collect::<Vec<_>>()
580            .join(", ");
581        let tokens_display = if addr.tokens.len() > 3 {
582            format!("{} (+{})", token_list, addr.tokens.len() - 3)
583        } else {
584            token_list
585        };
586        md.push_str(&format!(
587            "| `{}` | {} | {} | {} | {} | {} |\n",
588            addr.address,
589            label,
590            addr.chain,
591            addr.balance,
592            usd,
593            if tokens_display.is_empty() {
594                "-"
595            } else {
596                &tokens_display
597            }
598        ));
599    }
600
601    md.push_str(&crate::display::report::report_footer());
602    md
603}
604
605/// Fetches the balance for an address on the specified chain using the factory.
606async fn fetch_address_balance(
607    address: &str,
608    chain: &str,
609    clients: &dyn ChainClientFactory,
610    _include_tokens: bool,
611) -> (String, Vec<TokenSummary>) {
612    let client = match clients.create_chain_client(chain) {
613        Ok(c) => c,
614        Err(e) => {
615            tracing::error!(error = %e, chain = %chain, "Failed to create chain client");
616            return ("Error".to_string(), vec![]);
617        }
618    };
619
620    // Fetch native balance
621    let native_balance = match client.get_balance(address).await {
622        Ok(bal) => bal.formatted,
623        Err(e) => {
624            tracing::error!(error = %e, address = %address, "Failed to fetch balance");
625            "Error".to_string()
626        }
627    };
628
629    // Always fetch token balances for portfolio summary
630    let tokens = match client.get_token_balances(address).await {
631        Ok(token_bals) => token_bals
632            .into_iter()
633            .map(|tb| TokenSummary {
634                mint: tb.token.contract_address,
635                balance: tb.formatted_balance,
636                decimals: tb.token.decimals,
637                symbol: Some(tb.token.symbol),
638            })
639            .collect(),
640        Err(e) => {
641            tracing::warn!(error = %e, "Could not fetch token balances");
642            vec![]
643        }
644    };
645
646    (native_balance, tokens)
647}
648
649/// Returns the native token symbol for a chain.
650fn get_native_symbol(chain: &str) -> String {
651    match chain.to_lowercase().as_str() {
652        "solana" | "sol" => "SOL".to_string(),
653        "ethereum" | "eth" => "ETH".to_string(),
654        "tron" | "trx" => "TRX".to_string(),
655        _ => "???".to_string(),
656    }
657}
658
659// ============================================================================
660// Unit Tests
661// ============================================================================
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use tempfile::TempDir;
667
668    fn create_test_portfolio() -> Portfolio {
669        Portfolio {
670            addresses: vec![
671                WatchedAddress {
672                    address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
673                    label: Some("Main Wallet".to_string()),
674                    chain: "ethereum".to_string(),
675                    tags: vec!["personal".to_string()],
676                    added_at: 1700000000,
677                },
678                WatchedAddress {
679                    address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
680                    label: None,
681                    chain: "polygon".to_string(),
682                    tags: vec![],
683                    added_at: 1700000001,
684                },
685            ],
686        }
687    }
688
689    #[test]
690    fn test_portfolio_default() {
691        let portfolio = Portfolio::default();
692        assert!(portfolio.addresses.is_empty());
693    }
694
695    #[test]
696    fn test_portfolio_add_address() {
697        let mut portfolio = Portfolio::default();
698
699        let watched = WatchedAddress {
700            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
701            label: Some("Test".to_string()),
702            chain: "ethereum".to_string(),
703            tags: vec![],
704            added_at: 0,
705        };
706
707        let result = portfolio.add_address(watched);
708        assert!(result.is_ok());
709        assert_eq!(portfolio.addresses.len(), 1);
710    }
711
712    #[test]
713    fn test_portfolio_add_duplicate_fails() {
714        let mut portfolio = Portfolio::default();
715
716        let watched1 = WatchedAddress {
717            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
718            label: Some("First".to_string()),
719            chain: "ethereum".to_string(),
720            tags: vec![],
721            added_at: 0,
722        };
723
724        let watched2 = WatchedAddress {
725            address: "0x742d35CC6634C0532925A3b844Bc9e7595f1b3c2".to_string(), // Same address, different case
726            label: Some("Second".to_string()),
727            chain: "ethereum".to_string(),
728            tags: vec![],
729            added_at: 0,
730        };
731
732        portfolio.add_address(watched1).unwrap();
733        let result = portfolio.add_address(watched2);
734
735        assert!(result.is_err());
736        assert!(
737            result
738                .unwrap_err()
739                .to_string()
740                .contains("already in portfolio")
741        );
742    }
743
744    #[test]
745    fn test_portfolio_remove_address() {
746        let mut portfolio = create_test_portfolio();
747        let original_len = portfolio.addresses.len();
748
749        let removed = portfolio
750            .remove_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2")
751            .unwrap();
752
753        assert!(removed);
754        assert_eq!(portfolio.addresses.len(), original_len - 1);
755    }
756
757    #[test]
758    fn test_portfolio_remove_nonexistent() {
759        let mut portfolio = create_test_portfolio();
760        let original_len = portfolio.addresses.len();
761
762        let removed = portfolio.remove_address("0xnonexistent").unwrap();
763
764        assert!(!removed);
765        assert_eq!(portfolio.addresses.len(), original_len);
766    }
767
768    #[test]
769    fn test_portfolio_find_address() {
770        let portfolio = create_test_portfolio();
771
772        let found = portfolio.find_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
773        assert!(found.is_some());
774        assert_eq!(found.unwrap().label, Some("Main Wallet".to_string()));
775
776        let not_found = portfolio.find_address("0xnonexistent");
777        assert!(not_found.is_none());
778    }
779
780    #[test]
781    fn test_portfolio_find_address_case_insensitive() {
782        let portfolio = create_test_portfolio();
783
784        let found = portfolio.find_address("0x742D35CC6634C0532925A3B844BC9E7595F1B3C2");
785        assert!(found.is_some());
786    }
787
788    #[test]
789    fn test_portfolio_save_and_load() {
790        let temp_dir = TempDir::new().unwrap();
791        let data_dir = temp_dir.path().to_path_buf();
792
793        let portfolio = create_test_portfolio();
794        portfolio.save(&data_dir).unwrap();
795
796        let loaded = Portfolio::load(&data_dir).unwrap();
797        assert_eq!(loaded.addresses.len(), portfolio.addresses.len());
798        assert_eq!(loaded.addresses[0].address, portfolio.addresses[0].address);
799    }
800
801    #[test]
802    fn test_portfolio_load_nonexistent_returns_default() {
803        let temp_dir = TempDir::new().unwrap();
804        let data_dir = temp_dir.path().to_path_buf();
805
806        let portfolio = Portfolio::load(&data_dir).unwrap();
807        assert!(portfolio.addresses.is_empty());
808    }
809
810    #[test]
811    fn test_watched_address_serialization() {
812        let watched = WatchedAddress {
813            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
814            label: Some("Test".to_string()),
815            chain: "ethereum".to_string(),
816            tags: vec!["tag1".to_string(), "tag2".to_string()],
817            added_at: 1700000000,
818        };
819
820        let json = serde_json::to_string(&watched).unwrap();
821        assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
822        assert!(json.contains("Test"));
823        assert!(json.contains("tag1"));
824
825        let deserialized: WatchedAddress = serde_json::from_str(&json).unwrap();
826        assert_eq!(deserialized.address, watched.address);
827        assert_eq!(deserialized.tags.len(), 2);
828    }
829
830    #[test]
831    fn test_portfolio_summary_serialization() {
832        let summary = PortfolioSummary {
833            address_count: 2,
834            balances_by_chain: HashMap::new(),
835            total_usd: Some(10000.0),
836            addresses: vec![AddressSummary {
837                address: "0x123".to_string(),
838                label: Some("Test".to_string()),
839                chain: "ethereum".to_string(),
840                balance: "1.5".to_string(),
841                usd: Some(5000.0),
842                tokens: vec![],
843            }],
844        };
845
846        let json = serde_json::to_string(&summary).unwrap();
847        assert!(json.contains("10000"));
848        assert!(json.contains("0x123"));
849    }
850
851    #[test]
852    fn test_portfolio_args_parsing() {
853        use clap::Parser;
854
855        #[derive(Parser)]
856        struct TestCli {
857            #[command(flatten)]
858            args: PortfolioArgs,
859        }
860
861        let cli = TestCli::try_parse_from(["test", "list"]).unwrap();
862        assert!(matches!(cli.args.command, PortfolioCommands::List));
863    }
864
865    #[test]
866    fn test_portfolio_add_args_parsing() {
867        use clap::Parser;
868
869        #[derive(Parser)]
870        struct TestCli {
871            #[command(flatten)]
872            args: PortfolioArgs,
873        }
874
875        let cli = TestCli::try_parse_from([
876            "test",
877            "add",
878            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
879            "--label",
880            "My Wallet",
881            "--chain",
882            "polygon",
883            "--tags",
884            "personal,defi",
885        ])
886        .unwrap();
887
888        if let PortfolioCommands::Add(add_args) = cli.args.command {
889            assert_eq!(
890                add_args.address,
891                "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
892            );
893            assert_eq!(add_args.label, Some("My Wallet".to_string()));
894            assert_eq!(add_args.chain, "polygon");
895            assert_eq!(add_args.tags, vec!["personal", "defi"]);
896        } else {
897            panic!("Expected Add command");
898        }
899    }
900
901    #[test]
902    fn test_chain_balance_serialization() {
903        let balance = ChainBalance {
904            native_balance: "10.5".to_string(),
905            symbol: "ETH".to_string(),
906            usd: Some(35000.0),
907        };
908
909        let json = serde_json::to_string(&balance).unwrap();
910        assert!(json.contains("10.5"));
911        assert!(json.contains("ETH"));
912        assert!(json.contains("35000"));
913    }
914
915    // ========================================================================
916    // Native symbol tests
917    // ========================================================================
918
919    #[test]
920    fn test_get_native_symbol_solana() {
921        assert_eq!(get_native_symbol("solana"), "SOL");
922        assert_eq!(get_native_symbol("sol"), "SOL");
923    }
924
925    #[test]
926    fn test_get_native_symbol_ethereum() {
927        assert_eq!(get_native_symbol("ethereum"), "ETH");
928        assert_eq!(get_native_symbol("eth"), "ETH");
929    }
930
931    #[test]
932    fn test_get_native_symbol_tron() {
933        assert_eq!(get_native_symbol("tron"), "TRX");
934        assert_eq!(get_native_symbol("trx"), "TRX");
935    }
936
937    #[test]
938    fn test_get_native_symbol_unknown() {
939        assert_eq!(get_native_symbol("bitcoin"), "???");
940        assert_eq!(get_native_symbol("unknown"), "???");
941    }
942
943    // ========================================================================
944    // End-to-end tests using MockClientFactory
945    // ========================================================================
946
947    use crate::chains::mocks::MockClientFactory;
948
949    fn mock_factory() -> MockClientFactory {
950        MockClientFactory::new()
951    }
952
953    #[tokio::test]
954    async fn test_run_portfolio_list_empty() {
955        let tmp_dir = tempfile::tempdir().unwrap();
956        let config = Config {
957            portfolio: crate::config::PortfolioConfig {
958                data_dir: Some(tmp_dir.path().to_path_buf()),
959            },
960            ..Default::default()
961        };
962        let factory = mock_factory();
963        let args = PortfolioArgs {
964            command: PortfolioCommands::List,
965            format: Some(OutputFormat::Table),
966        };
967        let result = super::run(args, &config, &factory).await;
968        assert!(result.is_ok());
969    }
970
971    #[tokio::test]
972    async fn test_run_portfolio_add_and_list() {
973        let tmp_dir = tempfile::tempdir().unwrap();
974        let config = Config {
975            portfolio: crate::config::PortfolioConfig {
976                data_dir: Some(tmp_dir.path().to_path_buf()),
977            },
978            ..Default::default()
979        };
980        let factory = mock_factory();
981
982        // Add address
983        let add_args = PortfolioArgs {
984            command: PortfolioCommands::Add(AddArgs {
985                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
986                label: Some("Test Wallet".to_string()),
987                chain: "ethereum".to_string(),
988                tags: vec!["test".to_string()],
989            }),
990            format: Some(OutputFormat::Table),
991        };
992        let result = super::run(add_args, &config, &factory).await;
993        assert!(result.is_ok());
994
995        // List
996        let list_args = PortfolioArgs {
997            command: PortfolioCommands::List,
998            format: Some(OutputFormat::Json),
999        };
1000        let result = super::run(list_args, &config, &factory).await;
1001        assert!(result.is_ok());
1002    }
1003
1004    #[tokio::test]
1005    async fn test_run_portfolio_summary_with_mock() {
1006        let tmp_dir = tempfile::tempdir().unwrap();
1007        let config = Config {
1008            portfolio: crate::config::PortfolioConfig {
1009                data_dir: Some(tmp_dir.path().to_path_buf()),
1010            },
1011            ..Default::default()
1012        };
1013        let factory = mock_factory();
1014
1015        // Add address first
1016        let add_args = PortfolioArgs {
1017            command: PortfolioCommands::Add(AddArgs {
1018                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1019                label: Some("Test".to_string()),
1020                chain: "ethereum".to_string(),
1021                tags: vec![],
1022            }),
1023            format: None,
1024        };
1025        super::run(add_args, &config, &factory).await.unwrap();
1026
1027        // Summary
1028        let summary_args = PortfolioArgs {
1029            command: PortfolioCommands::Summary(SummaryArgs {
1030                chain: None,
1031                tag: None,
1032                include_tokens: false,
1033                report: None,
1034            }),
1035            format: Some(OutputFormat::Json),
1036        };
1037        let result = super::run(summary_args, &config, &factory).await;
1038        assert!(result.is_ok());
1039    }
1040
1041    #[tokio::test]
1042    async fn test_run_portfolio_remove() {
1043        let tmp_dir = tempfile::tempdir().unwrap();
1044        let config = Config {
1045            portfolio: crate::config::PortfolioConfig {
1046                data_dir: Some(tmp_dir.path().to_path_buf()),
1047            },
1048            ..Default::default()
1049        };
1050        let factory = mock_factory();
1051
1052        // Add then remove
1053        let add_args = PortfolioArgs {
1054            command: PortfolioCommands::Add(AddArgs {
1055                address: "0xtest".to_string(),
1056                label: None,
1057                chain: "ethereum".to_string(),
1058                tags: vec![],
1059            }),
1060            format: None,
1061        };
1062        super::run(add_args, &config, &factory).await.unwrap();
1063
1064        let remove_args = PortfolioArgs {
1065            command: PortfolioCommands::Remove(RemoveArgs {
1066                address: "0xtest".to_string(),
1067            }),
1068            format: None,
1069        };
1070        let result = super::run(remove_args, &config, &factory).await;
1071        assert!(result.is_ok());
1072    }
1073
1074    #[tokio::test]
1075    async fn test_run_portfolio_summary_csv() {
1076        let tmp_dir = tempfile::tempdir().unwrap();
1077        let config = Config {
1078            portfolio: crate::config::PortfolioConfig {
1079                data_dir: Some(tmp_dir.path().to_path_buf()),
1080            },
1081            ..Default::default()
1082        };
1083        let factory = mock_factory();
1084
1085        // Add address
1086        let add_args = PortfolioArgs {
1087            command: PortfolioCommands::Add(AddArgs {
1088                address: "0xtest".to_string(),
1089                label: Some("TestAddr".to_string()),
1090                chain: "ethereum".to_string(),
1091                tags: vec!["defi".to_string()],
1092            }),
1093            format: None,
1094        };
1095        super::run(add_args, &config, &factory).await.unwrap();
1096
1097        // CSV summary
1098        let summary_args = PortfolioArgs {
1099            command: PortfolioCommands::Summary(SummaryArgs {
1100                chain: None,
1101                tag: None,
1102                include_tokens: false,
1103                report: None,
1104            }),
1105            format: Some(OutputFormat::Csv),
1106        };
1107        let result = super::run(summary_args, &config, &factory).await;
1108        assert!(result.is_ok());
1109    }
1110
1111    #[tokio::test]
1112    async fn test_run_portfolio_summary_table() {
1113        let tmp_dir = tempfile::tempdir().unwrap();
1114        let config = Config {
1115            portfolio: crate::config::PortfolioConfig {
1116                data_dir: Some(tmp_dir.path().to_path_buf()),
1117            },
1118            ..Default::default()
1119        };
1120        let factory = mock_factory();
1121
1122        // Add address
1123        let add_args = PortfolioArgs {
1124            command: PortfolioCommands::Add(AddArgs {
1125                address: "0xtest".to_string(),
1126                label: Some("TestAddr".to_string()),
1127                chain: "ethereum".to_string(),
1128                tags: vec![],
1129            }),
1130            format: None,
1131        };
1132        super::run(add_args, &config, &factory).await.unwrap();
1133
1134        // Table summary
1135        let summary_args = PortfolioArgs {
1136            command: PortfolioCommands::Summary(SummaryArgs {
1137                chain: None,
1138                tag: None,
1139                include_tokens: true,
1140                report: None,
1141            }),
1142            format: Some(OutputFormat::Table),
1143        };
1144        let result = super::run(summary_args, &config, &factory).await;
1145        assert!(result.is_ok());
1146    }
1147
1148    #[tokio::test]
1149    async fn test_run_portfolio_summary_with_chain_filter() {
1150        let tmp_dir = tempfile::tempdir().unwrap();
1151        let config = Config {
1152            portfolio: crate::config::PortfolioConfig {
1153                data_dir: Some(tmp_dir.path().to_path_buf()),
1154            },
1155            ..Default::default()
1156        };
1157        let factory = mock_factory();
1158
1159        // Add addresses on different chains
1160        let add_eth = PortfolioArgs {
1161            command: PortfolioCommands::Add(AddArgs {
1162                address: "0xeth".to_string(),
1163                label: None,
1164                chain: "ethereum".to_string(),
1165                tags: vec![],
1166            }),
1167            format: None,
1168        };
1169        super::run(add_eth, &config, &factory).await.unwrap();
1170
1171        let add_poly = PortfolioArgs {
1172            command: PortfolioCommands::Add(AddArgs {
1173                address: "0xpoly".to_string(),
1174                label: None,
1175                chain: "polygon".to_string(),
1176                tags: vec![],
1177            }),
1178            format: None,
1179        };
1180        super::run(add_poly, &config, &factory).await.unwrap();
1181
1182        // Filter by chain
1183        let summary_args = PortfolioArgs {
1184            command: PortfolioCommands::Summary(SummaryArgs {
1185                chain: Some("ethereum".to_string()),
1186                tag: None,
1187                include_tokens: false,
1188                report: None,
1189            }),
1190            format: Some(OutputFormat::Json),
1191        };
1192        let result = super::run(summary_args, &config, &factory).await;
1193        assert!(result.is_ok());
1194    }
1195
1196    #[tokio::test]
1197    async fn test_run_portfolio_summary_with_tag_filter() {
1198        let tmp_dir = tempfile::tempdir().unwrap();
1199        let config = Config {
1200            portfolio: crate::config::PortfolioConfig {
1201                data_dir: Some(tmp_dir.path().to_path_buf()),
1202            },
1203            ..Default::default()
1204        };
1205        let factory = mock_factory();
1206
1207        // Add addresses with tags
1208        let add_args = PortfolioArgs {
1209            command: PortfolioCommands::Add(AddArgs {
1210                address: "0xdefi".to_string(),
1211                label: None,
1212                chain: "ethereum".to_string(),
1213                tags: vec!["defi".to_string()],
1214            }),
1215            format: None,
1216        };
1217        super::run(add_args, &config, &factory).await.unwrap();
1218
1219        // Filter by tag
1220        let summary_args = PortfolioArgs {
1221            command: PortfolioCommands::Summary(SummaryArgs {
1222                chain: None,
1223                tag: Some("defi".to_string()),
1224                include_tokens: false,
1225                report: None,
1226            }),
1227            format: Some(OutputFormat::Json),
1228        };
1229        let result = super::run(summary_args, &config, &factory).await;
1230        assert!(result.is_ok());
1231    }
1232
1233    #[tokio::test]
1234    async fn test_run_portfolio_summary_no_format() {
1235        let tmp_dir = tempfile::tempdir().unwrap();
1236        let config = Config {
1237            portfolio: crate::config::PortfolioConfig {
1238                data_dir: Some(tmp_dir.path().to_path_buf()),
1239            },
1240            ..Default::default()
1241        };
1242        let factory = mock_factory();
1243
1244        let add_args = PortfolioArgs {
1245            command: PortfolioCommands::Add(AddArgs {
1246                address: "0xtest".to_string(),
1247                label: None,
1248                chain: "ethereum".to_string(),
1249                tags: vec![],
1250            }),
1251            format: None,
1252        };
1253        super::run(add_args, &config, &factory).await.unwrap();
1254
1255        let summary_args = PortfolioArgs {
1256            command: PortfolioCommands::Summary(SummaryArgs {
1257                chain: None,
1258                tag: None,
1259                include_tokens: false,
1260                report: None,
1261            }),
1262            format: None, // Default format
1263        };
1264        let result = super::run(summary_args, &config, &factory).await;
1265        assert!(result.is_ok());
1266    }
1267
1268    #[tokio::test]
1269    async fn test_run_portfolio_summary_empty() {
1270        let tmp_dir = tempfile::tempdir().unwrap();
1271        let config = Config {
1272            portfolio: crate::config::PortfolioConfig {
1273                data_dir: Some(tmp_dir.path().to_path_buf()),
1274            },
1275            ..Default::default()
1276        };
1277        let factory = mock_factory();
1278
1279        // Summary with no addresses added
1280        let summary_args = PortfolioArgs {
1281            command: PortfolioCommands::Summary(SummaryArgs {
1282                chain: None,
1283                tag: None,
1284                include_tokens: false,
1285                report: None,
1286            }),
1287            format: Some(OutputFormat::Table),
1288        };
1289        let result = super::run(summary_args, &config, &factory).await;
1290        assert!(result.is_ok());
1291    }
1292
1293    #[tokio::test]
1294    async fn test_run_portfolio_add_with_tags() {
1295        let tmp_dir = tempfile::tempdir().unwrap();
1296        let config = Config {
1297            portfolio: crate::config::PortfolioConfig {
1298                data_dir: Some(tmp_dir.path().to_path_buf()),
1299            },
1300            ..Default::default()
1301        };
1302        let factory = mock_factory();
1303
1304        let add_args = PortfolioArgs {
1305            command: PortfolioCommands::Add(AddArgs {
1306                address: "0xtagged".to_string(),
1307                label: Some("Tagged".to_string()),
1308                chain: "ethereum".to_string(),
1309                tags: vec!["defi".to_string(), "whale".to_string()],
1310            }),
1311            format: None,
1312        };
1313        let result = super::run(add_args, &config, &factory).await;
1314        assert!(result.is_ok());
1315    }
1316
1317    #[test]
1318    fn test_get_native_symbol_polygon() {
1319        assert_eq!(get_native_symbol("polygon"), "???");
1320    }
1321
1322    #[test]
1323    fn test_get_native_symbol_bsc() {
1324        assert_eq!(get_native_symbol("bsc"), "???");
1325    }
1326
1327    #[tokio::test]
1328    async fn test_run_portfolio_list_csv_format() {
1329        let tmp_dir = tempfile::tempdir().unwrap();
1330        let config = Config {
1331            portfolio: crate::config::PortfolioConfig {
1332                data_dir: Some(tmp_dir.path().to_path_buf()),
1333            },
1334            ..Default::default()
1335        };
1336        let factory = mock_factory();
1337
1338        // Add address
1339        let add_args = PortfolioArgs {
1340            command: PortfolioCommands::Add(AddArgs {
1341                address: "0xCSV_test".to_string(),
1342                label: Some("CsvAddr".to_string()),
1343                chain: "ethereum".to_string(),
1344                tags: vec!["test".to_string()],
1345            }),
1346            format: None,
1347        };
1348        super::run(add_args, &config, &factory).await.unwrap();
1349
1350        // List with CSV
1351        let list_args = PortfolioArgs {
1352            command: PortfolioCommands::List,
1353            format: Some(OutputFormat::Csv),
1354        };
1355        let result = super::run(list_args, &config, &factory).await;
1356        assert!(result.is_ok());
1357    }
1358
1359    #[tokio::test]
1360    async fn test_run_portfolio_list_table_format() {
1361        let tmp_dir = tempfile::tempdir().unwrap();
1362        let config = Config {
1363            portfolio: crate::config::PortfolioConfig {
1364                data_dir: Some(tmp_dir.path().to_path_buf()),
1365            },
1366            ..Default::default()
1367        };
1368        let factory = mock_factory();
1369
1370        // Add addresses with and without labels
1371        let add_args = PortfolioArgs {
1372            command: PortfolioCommands::Add(AddArgs {
1373                address: "0xTable_test1".to_string(),
1374                label: Some("LabeledAddr".to_string()),
1375                chain: "ethereum".to_string(),
1376                tags: vec!["personal".to_string(), "defi".to_string()],
1377            }),
1378            format: None,
1379        };
1380        super::run(add_args, &config, &factory).await.unwrap();
1381
1382        let add_args2 = PortfolioArgs {
1383            command: PortfolioCommands::Add(AddArgs {
1384                address: "0xTable_test2".to_string(),
1385                label: None,
1386                chain: "polygon".to_string(),
1387                tags: vec![],
1388            }),
1389            format: None,
1390        };
1391        super::run(add_args2, &config, &factory).await.unwrap();
1392
1393        // List with Table
1394        let list_args = PortfolioArgs {
1395            command: PortfolioCommands::List,
1396            format: Some(OutputFormat::Table),
1397        };
1398        let result = super::run(list_args, &config, &factory).await;
1399        assert!(result.is_ok());
1400    }
1401
1402    #[tokio::test]
1403    async fn test_run_portfolio_summary_table_with_tokens() {
1404        let tmp_dir = tempfile::tempdir().unwrap();
1405        let config = Config {
1406            portfolio: crate::config::PortfolioConfig {
1407                data_dir: Some(tmp_dir.path().to_path_buf()),
1408            },
1409            ..Default::default()
1410        };
1411        let factory = mock_factory();
1412
1413        // Add address
1414        let add_args = PortfolioArgs {
1415            command: PortfolioCommands::Add(AddArgs {
1416                address: "0xTokenTest".to_string(),
1417                label: Some("TokenAddr".to_string()),
1418                chain: "ethereum".to_string(),
1419                tags: vec![],
1420            }),
1421            format: None,
1422        };
1423        super::run(add_args, &config, &factory).await.unwrap();
1424
1425        // Summary with Table and tokens included
1426        let summary_args = PortfolioArgs {
1427            command: PortfolioCommands::Summary(SummaryArgs {
1428                chain: None,
1429                tag: None,
1430                include_tokens: true,
1431                report: None,
1432            }),
1433            format: Some(OutputFormat::Table),
1434        };
1435        let result = super::run(summary_args, &config, &factory).await;
1436        assert!(result.is_ok());
1437    }
1438
1439    #[tokio::test]
1440    async fn test_run_portfolio_summary_multiple_chains() {
1441        let tmp_dir = tempfile::tempdir().unwrap();
1442        let config = Config {
1443            portfolio: crate::config::PortfolioConfig {
1444                data_dir: Some(tmp_dir.path().to_path_buf()),
1445            },
1446            ..Default::default()
1447        };
1448        let factory = mock_factory();
1449
1450        // Add addresses on the same chain to test chain balance aggregation
1451        let add1 = PortfolioArgs {
1452            command: PortfolioCommands::Add(AddArgs {
1453                address: "0xMulti1".to_string(),
1454                label: None,
1455                chain: "ethereum".to_string(),
1456                tags: vec![],
1457            }),
1458            format: None,
1459        };
1460        super::run(add1, &config, &factory).await.unwrap();
1461
1462        let add2 = PortfolioArgs {
1463            command: PortfolioCommands::Add(AddArgs {
1464                address: "0xMulti2".to_string(),
1465                label: None,
1466                chain: "ethereum".to_string(),
1467                tags: vec![],
1468            }),
1469            format: None,
1470        };
1471        super::run(add2, &config, &factory).await.unwrap();
1472
1473        // Summary - should aggregate chain balances
1474        let summary_args = PortfolioArgs {
1475            command: PortfolioCommands::Summary(SummaryArgs {
1476                chain: None,
1477                tag: None,
1478                include_tokens: false,
1479                report: None,
1480            }),
1481            format: Some(OutputFormat::Table),
1482        };
1483        let result = super::run(summary_args, &config, &factory).await;
1484        assert!(result.is_ok());
1485    }
1486
1487    #[tokio::test]
1488    async fn test_run_portfolio_list_no_format() {
1489        let tmp_dir = tempfile::tempdir().unwrap();
1490        let config = Config {
1491            portfolio: crate::config::PortfolioConfig {
1492                data_dir: Some(tmp_dir.path().to_path_buf()),
1493            },
1494            ..Default::default()
1495        };
1496        let factory = mock_factory();
1497
1498        // Add address
1499        let add_args = PortfolioArgs {
1500            command: PortfolioCommands::Add(AddArgs {
1501                address: "0xNoFmt".to_string(),
1502                label: Some("Test".to_string()),
1503                chain: "ethereum".to_string(),
1504                tags: vec![],
1505            }),
1506            format: None,
1507        };
1508        super::run(add_args, &config, &factory).await.unwrap();
1509
1510        // List with default format (None -> Table)
1511        let list_args = PortfolioArgs {
1512            command: PortfolioCommands::List,
1513            format: None,
1514        };
1515        let result = super::run(list_args, &config, &factory).await;
1516        assert!(result.is_ok());
1517    }
1518}