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