Skip to main content

scope/cli/
portfolio.rs

1//! # Portfolio Management Command
2//!
3//! This module implements the `scope 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//! scope portfolio add 0x742d... --label "Main Wallet"
11//!
12//! # List watched addresses
13//! scope portfolio list
14//!
15//! # Remove an address
16//! scope portfolio remove 0x742d...
17//!
18//! # View portfolio summary
19//! scope portfolio summary
20//! ```
21
22use crate::chains::{ChainClientFactory, native_symbol};
23use crate::config::{Config, OutputFormat};
24use crate::error::{Result, ScopeError};
25use clap::{Args, Subcommand};
26use serde::{Deserialize, Serialize};
27use std::collections::HashMap;
28use std::path::PathBuf;
29
30/// Arguments for the 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 contract/mint address.
194    pub contract_address: 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 'scope 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 'scope 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: native_symbol(&watched.chain).to_string(),
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 addr_short = if token.contract_address.len() >= 8 {
506                        &token.contract_address[..8]
507                    } else {
508                        &token.contract_address
509                    };
510                    let symbol = token.symbol.as_deref().unwrap_or(addr_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.contract_address))
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                contract_address: 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// ============================================================================
650// Unit Tests
651// ============================================================================
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use tempfile::TempDir;
657
658    fn create_test_portfolio() -> Portfolio {
659        Portfolio {
660            addresses: vec![
661                WatchedAddress {
662                    address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
663                    label: Some("Main Wallet".to_string()),
664                    chain: "ethereum".to_string(),
665                    tags: vec!["personal".to_string()],
666                    added_at: 1700000000,
667                },
668                WatchedAddress {
669                    address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
670                    label: None,
671                    chain: "polygon".to_string(),
672                    tags: vec![],
673                    added_at: 1700000001,
674                },
675            ],
676        }
677    }
678
679    #[test]
680    fn test_portfolio_default() {
681        let portfolio = Portfolio::default();
682        assert!(portfolio.addresses.is_empty());
683    }
684
685    #[test]
686    fn test_portfolio_add_address() {
687        let mut portfolio = Portfolio::default();
688
689        let watched = WatchedAddress {
690            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
691            label: Some("Test".to_string()),
692            chain: "ethereum".to_string(),
693            tags: vec![],
694            added_at: 0,
695        };
696
697        let result = portfolio.add_address(watched);
698        assert!(result.is_ok());
699        assert_eq!(portfolio.addresses.len(), 1);
700    }
701
702    #[test]
703    fn test_portfolio_add_duplicate_fails() {
704        let mut portfolio = Portfolio::default();
705
706        let watched1 = WatchedAddress {
707            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
708            label: Some("First".to_string()),
709            chain: "ethereum".to_string(),
710            tags: vec![],
711            added_at: 0,
712        };
713
714        let watched2 = WatchedAddress {
715            address: "0x742d35CC6634C0532925A3b844Bc9e7595f1b3c2".to_string(), // Same address, different case
716            label: Some("Second".to_string()),
717            chain: "ethereum".to_string(),
718            tags: vec![],
719            added_at: 0,
720        };
721
722        portfolio.add_address(watched1).unwrap();
723        let result = portfolio.add_address(watched2);
724
725        assert!(result.is_err());
726        assert!(
727            result
728                .unwrap_err()
729                .to_string()
730                .contains("already in portfolio")
731        );
732    }
733
734    #[test]
735    fn test_portfolio_remove_address() {
736        let mut portfolio = create_test_portfolio();
737        let original_len = portfolio.addresses.len();
738
739        let removed = portfolio
740            .remove_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2")
741            .unwrap();
742
743        assert!(removed);
744        assert_eq!(portfolio.addresses.len(), original_len - 1);
745    }
746
747    #[test]
748    fn test_portfolio_remove_nonexistent() {
749        let mut portfolio = create_test_portfolio();
750        let original_len = portfolio.addresses.len();
751
752        let removed = portfolio.remove_address("0xnonexistent").unwrap();
753
754        assert!(!removed);
755        assert_eq!(portfolio.addresses.len(), original_len);
756    }
757
758    #[test]
759    fn test_portfolio_find_address() {
760        let portfolio = create_test_portfolio();
761
762        let found = portfolio.find_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
763        assert!(found.is_some());
764        assert_eq!(found.unwrap().label, Some("Main Wallet".to_string()));
765
766        let not_found = portfolio.find_address("0xnonexistent");
767        assert!(not_found.is_none());
768    }
769
770    #[test]
771    fn test_portfolio_find_address_case_insensitive() {
772        let portfolio = create_test_portfolio();
773
774        let found = portfolio.find_address("0x742D35CC6634C0532925A3B844BC9E7595F1B3C2");
775        assert!(found.is_some());
776    }
777
778    #[test]
779    fn test_portfolio_save_and_load() {
780        let temp_dir = TempDir::new().unwrap();
781        let data_dir = temp_dir.path().to_path_buf();
782
783        let portfolio = create_test_portfolio();
784        portfolio.save(&data_dir).unwrap();
785
786        let loaded = Portfolio::load(&data_dir).unwrap();
787        assert_eq!(loaded.addresses.len(), portfolio.addresses.len());
788        assert_eq!(loaded.addresses[0].address, portfolio.addresses[0].address);
789    }
790
791    #[test]
792    fn test_portfolio_load_nonexistent_returns_default() {
793        let temp_dir = TempDir::new().unwrap();
794        let data_dir = temp_dir.path().to_path_buf();
795
796        let portfolio = Portfolio::load(&data_dir).unwrap();
797        assert!(portfolio.addresses.is_empty());
798    }
799
800    #[test]
801    fn test_watched_address_serialization() {
802        let watched = WatchedAddress {
803            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
804            label: Some("Test".to_string()),
805            chain: "ethereum".to_string(),
806            tags: vec!["tag1".to_string(), "tag2".to_string()],
807            added_at: 1700000000,
808        };
809
810        let json = serde_json::to_string(&watched).unwrap();
811        assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
812        assert!(json.contains("Test"));
813        assert!(json.contains("tag1"));
814
815        let deserialized: WatchedAddress = serde_json::from_str(&json).unwrap();
816        assert_eq!(deserialized.address, watched.address);
817        assert_eq!(deserialized.tags.len(), 2);
818    }
819
820    #[test]
821    fn test_portfolio_summary_serialization() {
822        let summary = PortfolioSummary {
823            address_count: 2,
824            balances_by_chain: HashMap::new(),
825            total_usd: Some(10000.0),
826            addresses: vec![AddressSummary {
827                address: "0x123".to_string(),
828                label: Some("Test".to_string()),
829                chain: "ethereum".to_string(),
830                balance: "1.5".to_string(),
831                usd: Some(5000.0),
832                tokens: vec![],
833            }],
834        };
835
836        let json = serde_json::to_string(&summary).unwrap();
837        assert!(json.contains("10000"));
838        assert!(json.contains("0x123"));
839    }
840
841    #[test]
842    fn test_portfolio_args_parsing() {
843        use clap::Parser;
844
845        #[derive(Parser)]
846        struct TestCli {
847            #[command(flatten)]
848            args: PortfolioArgs,
849        }
850
851        let cli = TestCli::try_parse_from(["test", "list"]).unwrap();
852        assert!(matches!(cli.args.command, PortfolioCommands::List));
853    }
854
855    #[test]
856    fn test_portfolio_add_args_parsing() {
857        use clap::Parser;
858
859        #[derive(Parser)]
860        struct TestCli {
861            #[command(flatten)]
862            args: PortfolioArgs,
863        }
864
865        let cli = TestCli::try_parse_from([
866            "test",
867            "add",
868            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
869            "--label",
870            "My Wallet",
871            "--chain",
872            "polygon",
873            "--tags",
874            "personal,defi",
875        ])
876        .unwrap();
877
878        if let PortfolioCommands::Add(add_args) = cli.args.command {
879            assert_eq!(
880                add_args.address,
881                "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
882            );
883            assert_eq!(add_args.label, Some("My Wallet".to_string()));
884            assert_eq!(add_args.chain, "polygon");
885            assert_eq!(add_args.tags, vec!["personal", "defi"]);
886        } else {
887            panic!("Expected Add command");
888        }
889    }
890
891    #[test]
892    fn test_chain_balance_serialization() {
893        let balance = ChainBalance {
894            native_balance: "10.5".to_string(),
895            symbol: "ETH".to_string(),
896            usd: Some(35000.0),
897        };
898
899        let json = serde_json::to_string(&balance).unwrap();
900        assert!(json.contains("10.5"));
901        assert!(json.contains("ETH"));
902        assert!(json.contains("35000"));
903    }
904
905    // ========================================================================
906    // Native symbol tests
907    // ========================================================================
908
909    #[test]
910    fn test_get_native_symbol_solana() {
911        assert_eq!(native_symbol("solana"), "SOL");
912        assert_eq!(native_symbol("sol"), "SOL");
913    }
914
915    #[test]
916    fn test_get_native_symbol_ethereum() {
917        assert_eq!(native_symbol("ethereum"), "ETH");
918        assert_eq!(native_symbol("eth"), "ETH");
919    }
920
921    #[test]
922    fn test_get_native_symbol_tron() {
923        assert_eq!(native_symbol("tron"), "TRX");
924        assert_eq!(native_symbol("trx"), "TRX");
925    }
926
927    #[test]
928    fn test_get_native_symbol_unknown() {
929        assert_eq!(native_symbol("bitcoin"), "???");
930        assert_eq!(native_symbol("unknown"), "???");
931    }
932
933    // ========================================================================
934    // End-to-end tests using MockClientFactory
935    // ========================================================================
936
937    use crate::chains::mocks::MockClientFactory;
938
939    fn mock_factory() -> MockClientFactory {
940        MockClientFactory::new()
941    }
942
943    #[tokio::test]
944    async fn test_run_portfolio_list_empty() {
945        let tmp_dir = tempfile::tempdir().unwrap();
946        let config = Config {
947            portfolio: crate::config::PortfolioConfig {
948                data_dir: Some(tmp_dir.path().to_path_buf()),
949            },
950            ..Default::default()
951        };
952        let factory = mock_factory();
953        let args = PortfolioArgs {
954            command: PortfolioCommands::List,
955            format: Some(OutputFormat::Table),
956        };
957        let result = super::run(args, &config, &factory).await;
958        assert!(result.is_ok());
959    }
960
961    #[tokio::test]
962    async fn test_run_portfolio_add_and_list() {
963        let tmp_dir = tempfile::tempdir().unwrap();
964        let config = Config {
965            portfolio: crate::config::PortfolioConfig {
966                data_dir: Some(tmp_dir.path().to_path_buf()),
967            },
968            ..Default::default()
969        };
970        let factory = mock_factory();
971
972        // Add address
973        let add_args = PortfolioArgs {
974            command: PortfolioCommands::Add(AddArgs {
975                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
976                label: Some("Test Wallet".to_string()),
977                chain: "ethereum".to_string(),
978                tags: vec!["test".to_string()],
979            }),
980            format: Some(OutputFormat::Table),
981        };
982        let result = super::run(add_args, &config, &factory).await;
983        assert!(result.is_ok());
984
985        // List
986        let list_args = PortfolioArgs {
987            command: PortfolioCommands::List,
988            format: Some(OutputFormat::Json),
989        };
990        let result = super::run(list_args, &config, &factory).await;
991        assert!(result.is_ok());
992    }
993
994    #[tokio::test]
995    async fn test_run_portfolio_summary_with_mock() {
996        let tmp_dir = tempfile::tempdir().unwrap();
997        let config = Config {
998            portfolio: crate::config::PortfolioConfig {
999                data_dir: Some(tmp_dir.path().to_path_buf()),
1000            },
1001            ..Default::default()
1002        };
1003        let factory = mock_factory();
1004
1005        // Add address first
1006        let add_args = PortfolioArgs {
1007            command: PortfolioCommands::Add(AddArgs {
1008                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1009                label: Some("Test".to_string()),
1010                chain: "ethereum".to_string(),
1011                tags: vec![],
1012            }),
1013            format: None,
1014        };
1015        super::run(add_args, &config, &factory).await.unwrap();
1016
1017        // Summary
1018        let summary_args = PortfolioArgs {
1019            command: PortfolioCommands::Summary(SummaryArgs {
1020                chain: None,
1021                tag: None,
1022                include_tokens: false,
1023                report: None,
1024            }),
1025            format: Some(OutputFormat::Json),
1026        };
1027        let result = super::run(summary_args, &config, &factory).await;
1028        assert!(result.is_ok());
1029    }
1030
1031    #[tokio::test]
1032    async fn test_run_portfolio_remove() {
1033        let tmp_dir = tempfile::tempdir().unwrap();
1034        let config = Config {
1035            portfolio: crate::config::PortfolioConfig {
1036                data_dir: Some(tmp_dir.path().to_path_buf()),
1037            },
1038            ..Default::default()
1039        };
1040        let factory = mock_factory();
1041
1042        // Add then remove
1043        let add_args = PortfolioArgs {
1044            command: PortfolioCommands::Add(AddArgs {
1045                address: "0xtest".to_string(),
1046                label: None,
1047                chain: "ethereum".to_string(),
1048                tags: vec![],
1049            }),
1050            format: None,
1051        };
1052        super::run(add_args, &config, &factory).await.unwrap();
1053
1054        let remove_args = PortfolioArgs {
1055            command: PortfolioCommands::Remove(RemoveArgs {
1056                address: "0xtest".to_string(),
1057            }),
1058            format: None,
1059        };
1060        let result = super::run(remove_args, &config, &factory).await;
1061        assert!(result.is_ok());
1062    }
1063
1064    #[tokio::test]
1065    async fn test_run_portfolio_summary_csv() {
1066        let tmp_dir = tempfile::tempdir().unwrap();
1067        let config = Config {
1068            portfolio: crate::config::PortfolioConfig {
1069                data_dir: Some(tmp_dir.path().to_path_buf()),
1070            },
1071            ..Default::default()
1072        };
1073        let factory = mock_factory();
1074
1075        // Add address
1076        let add_args = PortfolioArgs {
1077            command: PortfolioCommands::Add(AddArgs {
1078                address: "0xtest".to_string(),
1079                label: Some("TestAddr".to_string()),
1080                chain: "ethereum".to_string(),
1081                tags: vec!["defi".to_string()],
1082            }),
1083            format: None,
1084        };
1085        super::run(add_args, &config, &factory).await.unwrap();
1086
1087        // CSV summary
1088        let summary_args = PortfolioArgs {
1089            command: PortfolioCommands::Summary(SummaryArgs {
1090                chain: None,
1091                tag: None,
1092                include_tokens: false,
1093                report: None,
1094            }),
1095            format: Some(OutputFormat::Csv),
1096        };
1097        let result = super::run(summary_args, &config, &factory).await;
1098        assert!(result.is_ok());
1099    }
1100
1101    #[tokio::test]
1102    async fn test_run_portfolio_summary_table() {
1103        let tmp_dir = tempfile::tempdir().unwrap();
1104        let config = Config {
1105            portfolio: crate::config::PortfolioConfig {
1106                data_dir: Some(tmp_dir.path().to_path_buf()),
1107            },
1108            ..Default::default()
1109        };
1110        let factory = mock_factory();
1111
1112        // Add address
1113        let add_args = PortfolioArgs {
1114            command: PortfolioCommands::Add(AddArgs {
1115                address: "0xtest".to_string(),
1116                label: Some("TestAddr".to_string()),
1117                chain: "ethereum".to_string(),
1118                tags: vec![],
1119            }),
1120            format: None,
1121        };
1122        super::run(add_args, &config, &factory).await.unwrap();
1123
1124        // Table summary
1125        let summary_args = PortfolioArgs {
1126            command: PortfolioCommands::Summary(SummaryArgs {
1127                chain: None,
1128                tag: None,
1129                include_tokens: true,
1130                report: None,
1131            }),
1132            format: Some(OutputFormat::Table),
1133        };
1134        let result = super::run(summary_args, &config, &factory).await;
1135        assert!(result.is_ok());
1136    }
1137
1138    #[tokio::test]
1139    async fn test_run_portfolio_summary_with_chain_filter() {
1140        let tmp_dir = tempfile::tempdir().unwrap();
1141        let config = Config {
1142            portfolio: crate::config::PortfolioConfig {
1143                data_dir: Some(tmp_dir.path().to_path_buf()),
1144            },
1145            ..Default::default()
1146        };
1147        let factory = mock_factory();
1148
1149        // Add addresses on different chains
1150        let add_eth = PortfolioArgs {
1151            command: PortfolioCommands::Add(AddArgs {
1152                address: "0xeth".to_string(),
1153                label: None,
1154                chain: "ethereum".to_string(),
1155                tags: vec![],
1156            }),
1157            format: None,
1158        };
1159        super::run(add_eth, &config, &factory).await.unwrap();
1160
1161        let add_poly = PortfolioArgs {
1162            command: PortfolioCommands::Add(AddArgs {
1163                address: "0xpoly".to_string(),
1164                label: None,
1165                chain: "polygon".to_string(),
1166                tags: vec![],
1167            }),
1168            format: None,
1169        };
1170        super::run(add_poly, &config, &factory).await.unwrap();
1171
1172        // Filter by chain
1173        let summary_args = PortfolioArgs {
1174            command: PortfolioCommands::Summary(SummaryArgs {
1175                chain: Some("ethereum".to_string()),
1176                tag: None,
1177                include_tokens: false,
1178                report: None,
1179            }),
1180            format: Some(OutputFormat::Json),
1181        };
1182        let result = super::run(summary_args, &config, &factory).await;
1183        assert!(result.is_ok());
1184    }
1185
1186    #[tokio::test]
1187    async fn test_run_portfolio_summary_with_tag_filter() {
1188        let tmp_dir = tempfile::tempdir().unwrap();
1189        let config = Config {
1190            portfolio: crate::config::PortfolioConfig {
1191                data_dir: Some(tmp_dir.path().to_path_buf()),
1192            },
1193            ..Default::default()
1194        };
1195        let factory = mock_factory();
1196
1197        // Add addresses with tags
1198        let add_args = PortfolioArgs {
1199            command: PortfolioCommands::Add(AddArgs {
1200                address: "0xdefi".to_string(),
1201                label: None,
1202                chain: "ethereum".to_string(),
1203                tags: vec!["defi".to_string()],
1204            }),
1205            format: None,
1206        };
1207        super::run(add_args, &config, &factory).await.unwrap();
1208
1209        // Filter by tag
1210        let summary_args = PortfolioArgs {
1211            command: PortfolioCommands::Summary(SummaryArgs {
1212                chain: None,
1213                tag: Some("defi".to_string()),
1214                include_tokens: false,
1215                report: None,
1216            }),
1217            format: Some(OutputFormat::Json),
1218        };
1219        let result = super::run(summary_args, &config, &factory).await;
1220        assert!(result.is_ok());
1221    }
1222
1223    #[tokio::test]
1224    async fn test_run_portfolio_summary_no_format() {
1225        let tmp_dir = tempfile::tempdir().unwrap();
1226        let config = Config {
1227            portfolio: crate::config::PortfolioConfig {
1228                data_dir: Some(tmp_dir.path().to_path_buf()),
1229            },
1230            ..Default::default()
1231        };
1232        let factory = mock_factory();
1233
1234        let add_args = PortfolioArgs {
1235            command: PortfolioCommands::Add(AddArgs {
1236                address: "0xtest".to_string(),
1237                label: None,
1238                chain: "ethereum".to_string(),
1239                tags: vec![],
1240            }),
1241            format: None,
1242        };
1243        super::run(add_args, &config, &factory).await.unwrap();
1244
1245        let summary_args = PortfolioArgs {
1246            command: PortfolioCommands::Summary(SummaryArgs {
1247                chain: None,
1248                tag: None,
1249                include_tokens: false,
1250                report: None,
1251            }),
1252            format: None, // Default format
1253        };
1254        let result = super::run(summary_args, &config, &factory).await;
1255        assert!(result.is_ok());
1256    }
1257
1258    #[tokio::test]
1259    async fn test_run_portfolio_summary_empty() {
1260        let tmp_dir = tempfile::tempdir().unwrap();
1261        let config = Config {
1262            portfolio: crate::config::PortfolioConfig {
1263                data_dir: Some(tmp_dir.path().to_path_buf()),
1264            },
1265            ..Default::default()
1266        };
1267        let factory = mock_factory();
1268
1269        // Summary with no addresses added
1270        let summary_args = PortfolioArgs {
1271            command: PortfolioCommands::Summary(SummaryArgs {
1272                chain: None,
1273                tag: None,
1274                include_tokens: false,
1275                report: None,
1276            }),
1277            format: Some(OutputFormat::Table),
1278        };
1279        let result = super::run(summary_args, &config, &factory).await;
1280        assert!(result.is_ok());
1281    }
1282
1283    #[tokio::test]
1284    async fn test_run_portfolio_add_with_tags() {
1285        let tmp_dir = tempfile::tempdir().unwrap();
1286        let config = Config {
1287            portfolio: crate::config::PortfolioConfig {
1288                data_dir: Some(tmp_dir.path().to_path_buf()),
1289            },
1290            ..Default::default()
1291        };
1292        let factory = mock_factory();
1293
1294        let add_args = PortfolioArgs {
1295            command: PortfolioCommands::Add(AddArgs {
1296                address: "0xtagged".to_string(),
1297                label: Some("Tagged".to_string()),
1298                chain: "ethereum".to_string(),
1299                tags: vec!["defi".to_string(), "whale".to_string()],
1300            }),
1301            format: None,
1302        };
1303        let result = super::run(add_args, &config, &factory).await;
1304        assert!(result.is_ok());
1305    }
1306
1307    #[test]
1308    fn test_get_native_symbol_polygon() {
1309        assert_eq!(native_symbol("polygon"), "MATIC");
1310    }
1311
1312    #[test]
1313    fn test_get_native_symbol_bsc() {
1314        assert_eq!(native_symbol("bsc"), "BNB");
1315    }
1316
1317    #[test]
1318    fn test_get_native_symbol_evm_l2s() {
1319        assert_eq!(native_symbol("arbitrum"), "ETH");
1320        assert_eq!(native_symbol("optimism"), "ETH");
1321        assert_eq!(native_symbol("base"), "ETH");
1322    }
1323
1324    #[tokio::test]
1325    async fn test_run_portfolio_list_csv_format() {
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 address
1336        let add_args = PortfolioArgs {
1337            command: PortfolioCommands::Add(AddArgs {
1338                address: "0xCSV_test".to_string(),
1339                label: Some("CsvAddr".to_string()),
1340                chain: "ethereum".to_string(),
1341                tags: vec!["test".to_string()],
1342            }),
1343            format: None,
1344        };
1345        super::run(add_args, &config, &factory).await.unwrap();
1346
1347        // List with CSV
1348        let list_args = PortfolioArgs {
1349            command: PortfolioCommands::List,
1350            format: Some(OutputFormat::Csv),
1351        };
1352        let result = super::run(list_args, &config, &factory).await;
1353        assert!(result.is_ok());
1354    }
1355
1356    #[tokio::test]
1357    async fn test_run_portfolio_list_table_format() {
1358        let tmp_dir = tempfile::tempdir().unwrap();
1359        let config = Config {
1360            portfolio: crate::config::PortfolioConfig {
1361                data_dir: Some(tmp_dir.path().to_path_buf()),
1362            },
1363            ..Default::default()
1364        };
1365        let factory = mock_factory();
1366
1367        // Add addresses with and without labels
1368        let add_args = PortfolioArgs {
1369            command: PortfolioCommands::Add(AddArgs {
1370                address: "0xTable_test1".to_string(),
1371                label: Some("LabeledAddr".to_string()),
1372                chain: "ethereum".to_string(),
1373                tags: vec!["personal".to_string(), "defi".to_string()],
1374            }),
1375            format: None,
1376        };
1377        super::run(add_args, &config, &factory).await.unwrap();
1378
1379        let add_args2 = PortfolioArgs {
1380            command: PortfolioCommands::Add(AddArgs {
1381                address: "0xTable_test2".to_string(),
1382                label: None,
1383                chain: "polygon".to_string(),
1384                tags: vec![],
1385            }),
1386            format: None,
1387        };
1388        super::run(add_args2, &config, &factory).await.unwrap();
1389
1390        // List with Table
1391        let list_args = PortfolioArgs {
1392            command: PortfolioCommands::List,
1393            format: Some(OutputFormat::Table),
1394        };
1395        let result = super::run(list_args, &config, &factory).await;
1396        assert!(result.is_ok());
1397    }
1398
1399    #[tokio::test]
1400    async fn test_run_portfolio_summary_table_with_tokens() {
1401        let tmp_dir = tempfile::tempdir().unwrap();
1402        let config = Config {
1403            portfolio: crate::config::PortfolioConfig {
1404                data_dir: Some(tmp_dir.path().to_path_buf()),
1405            },
1406            ..Default::default()
1407        };
1408        let factory = mock_factory();
1409
1410        // Add address
1411        let add_args = PortfolioArgs {
1412            command: PortfolioCommands::Add(AddArgs {
1413                address: "0xTokenTest".to_string(),
1414                label: Some("TokenAddr".to_string()),
1415                chain: "ethereum".to_string(),
1416                tags: vec![],
1417            }),
1418            format: None,
1419        };
1420        super::run(add_args, &config, &factory).await.unwrap();
1421
1422        // Summary with Table and tokens included
1423        let summary_args = PortfolioArgs {
1424            command: PortfolioCommands::Summary(SummaryArgs {
1425                chain: None,
1426                tag: None,
1427                include_tokens: true,
1428                report: None,
1429            }),
1430            format: Some(OutputFormat::Table),
1431        };
1432        let result = super::run(summary_args, &config, &factory).await;
1433        assert!(result.is_ok());
1434    }
1435
1436    #[tokio::test]
1437    async fn test_run_portfolio_summary_multiple_chains() {
1438        let tmp_dir = tempfile::tempdir().unwrap();
1439        let config = Config {
1440            portfolio: crate::config::PortfolioConfig {
1441                data_dir: Some(tmp_dir.path().to_path_buf()),
1442            },
1443            ..Default::default()
1444        };
1445        let factory = mock_factory();
1446
1447        // Add addresses on the same chain to test chain balance aggregation
1448        let add1 = PortfolioArgs {
1449            command: PortfolioCommands::Add(AddArgs {
1450                address: "0xMulti1".to_string(),
1451                label: None,
1452                chain: "ethereum".to_string(),
1453                tags: vec![],
1454            }),
1455            format: None,
1456        };
1457        super::run(add1, &config, &factory).await.unwrap();
1458
1459        let add2 = PortfolioArgs {
1460            command: PortfolioCommands::Add(AddArgs {
1461                address: "0xMulti2".to_string(),
1462                label: None,
1463                chain: "ethereum".to_string(),
1464                tags: vec![],
1465            }),
1466            format: None,
1467        };
1468        super::run(add2, &config, &factory).await.unwrap();
1469
1470        // Summary - should aggregate chain balances
1471        let summary_args = PortfolioArgs {
1472            command: PortfolioCommands::Summary(SummaryArgs {
1473                chain: None,
1474                tag: None,
1475                include_tokens: false,
1476                report: None,
1477            }),
1478            format: Some(OutputFormat::Table),
1479        };
1480        let result = super::run(summary_args, &config, &factory).await;
1481        assert!(result.is_ok());
1482    }
1483
1484    #[tokio::test]
1485    async fn test_run_portfolio_list_no_format() {
1486        let tmp_dir = tempfile::tempdir().unwrap();
1487        let config = Config {
1488            portfolio: crate::config::PortfolioConfig {
1489                data_dir: Some(tmp_dir.path().to_path_buf()),
1490            },
1491            ..Default::default()
1492        };
1493        let factory = mock_factory();
1494
1495        // Add address
1496        let add_args = PortfolioArgs {
1497            command: PortfolioCommands::Add(AddArgs {
1498                address: "0xNoFmt".to_string(),
1499                label: Some("Test".to_string()),
1500                chain: "ethereum".to_string(),
1501                tags: vec![],
1502            }),
1503            format: None,
1504        };
1505        super::run(add_args, &config, &factory).await.unwrap();
1506
1507        // List with default format (None -> Table)
1508        let list_args = PortfolioArgs {
1509            command: PortfolioCommands::List,
1510            format: None,
1511        };
1512        let result = super::run(list_args, &config, &factory).await;
1513        assert!(result.is_ok());
1514    }
1515
1516    #[test]
1517    fn test_portfolio_new() {
1518        let p = Portfolio::default();
1519        assert!(p.addresses.is_empty());
1520    }
1521
1522    #[test]
1523    fn test_portfolio_load_missing_dir() {
1524        let temp = tempfile::tempdir().unwrap();
1525        let p = Portfolio::load(temp.path());
1526        assert!(p.is_ok());
1527        assert!(p.unwrap().addresses.is_empty());
1528    }
1529
1530    #[test]
1531    fn test_portfolio_add_and_save_roundtrip() {
1532        let temp = tempfile::tempdir().unwrap();
1533        let mut p = Portfolio::default();
1534        let addr = WatchedAddress {
1535            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1536            label: Some("Test".to_string()),
1537            chain: "ethereum".to_string(),
1538            tags: vec!["tag1".to_string()],
1539            added_at: 1234567890,
1540        };
1541        p.add_address(addr).unwrap();
1542        assert_eq!(p.addresses.len(), 1);
1543
1544        let data_dir = temp.path().to_path_buf();
1545        p.save(&data_dir).unwrap();
1546        let loaded = Portfolio::load(temp.path()).unwrap();
1547        assert_eq!(loaded.addresses.len(), 1);
1548        assert_eq!(
1549            loaded.addresses[0].address,
1550            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
1551        );
1552        assert_eq!(loaded.addresses[0].label, Some("Test".to_string()));
1553    }
1554
1555    #[test]
1556    fn test_portfolio_add_duplicate() {
1557        let mut p = Portfolio::default();
1558        let addr1 = WatchedAddress {
1559            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1560            label: None,
1561            chain: "ethereum".to_string(),
1562            tags: vec![],
1563            added_at: 0,
1564        };
1565        let addr2 = WatchedAddress {
1566            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1567            label: None,
1568            chain: "ethereum".to_string(),
1569            tags: vec![],
1570            added_at: 0,
1571        };
1572        p.add_address(addr1).unwrap();
1573        let result = p.add_address(addr2);
1574        // Should error on duplicate
1575        assert!(result.is_err());
1576        assert!(
1577            result
1578                .unwrap_err()
1579                .to_string()
1580                .contains("already in portfolio")
1581        );
1582    }
1583
1584    #[test]
1585    fn test_watched_address_debug() {
1586        let addr = WatchedAddress {
1587            address: "0xtest".to_string(),
1588            label: Some("My Wallet".to_string()),
1589            chain: "ethereum".to_string(),
1590            tags: vec!["defi".to_string(), "staking".to_string()],
1591            added_at: 1700000000,
1592        };
1593        let debug = format!("{:?}", addr);
1594        assert!(debug.contains("WatchedAddress"));
1595        assert!(debug.contains("0xtest"));
1596    }
1597
1598    // ========================================================================
1599    // portfolio_summary_to_markdown tests
1600    // ========================================================================
1601
1602    #[test]
1603    fn test_portfolio_summary_to_markdown_basic() {
1604        let mut balances_by_chain = HashMap::new();
1605        balances_by_chain.insert(
1606            "ethereum".to_string(),
1607            ChainBalance {
1608                native_balance: "1.5".to_string(),
1609                symbol: "ETH".to_string(),
1610                usd: None,
1611            },
1612        );
1613
1614        let summary = PortfolioSummary {
1615            address_count: 2,
1616            balances_by_chain,
1617            total_usd: None,
1618            addresses: vec![
1619                AddressSummary {
1620                    address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1621                    label: Some("Main Wallet".to_string()),
1622                    chain: "ethereum".to_string(),
1623                    balance: "1.5".to_string(),
1624                    usd: None,
1625                    tokens: vec![],
1626                },
1627                AddressSummary {
1628                    address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
1629                    label: None,
1630                    chain: "polygon".to_string(),
1631                    balance: "100.0".to_string(),
1632                    usd: None,
1633                    tokens: vec![],
1634                },
1635            ],
1636        };
1637
1638        let md = portfolio_summary_to_markdown(&summary);
1639
1640        // Check header elements
1641        assert!(md.contains("# Portfolio Report"));
1642        assert!(md.contains("**Addresses:** 2"));
1643        assert!(md.contains("Allocation by Chain"));
1644        assert!(md.contains("## Addresses"));
1645
1646        // Check chain balance table
1647        assert!(md.contains("ethereum"));
1648        assert!(md.contains("1.5"));
1649        assert!(md.contains("ETH"));
1650
1651        // Check address table
1652        assert!(md.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
1653        assert!(md.contains("Main Wallet"));
1654        assert!(md.contains("0xABCdef1234567890abcdef1234567890ABCDEF12"));
1655        assert!(md.contains("polygon"));
1656        assert!(md.contains("100.0"));
1657
1658        // Check footer
1659        assert!(md.contains("Report generated by Scope"));
1660    }
1661
1662    #[test]
1663    fn test_portfolio_summary_to_markdown_with_usd() {
1664        let mut balances_by_chain = HashMap::new();
1665        balances_by_chain.insert(
1666            "ethereum".to_string(),
1667            ChainBalance {
1668                native_balance: "2.0".to_string(),
1669                symbol: "ETH".to_string(),
1670                usd: Some(3000.0),
1671            },
1672        );
1673
1674        let summary = PortfolioSummary {
1675            address_count: 2,
1676            balances_by_chain,
1677            total_usd: Some(5000.0),
1678            addresses: vec![
1679                AddressSummary {
1680                    address: "0x1234567890123456789012345678901234567890".to_string(),
1681                    label: Some("Wallet 1".to_string()),
1682                    chain: "ethereum".to_string(),
1683                    balance: "2.0".to_string(),
1684                    usd: Some(3000.0),
1685                    tokens: vec![],
1686                },
1687                AddressSummary {
1688                    address: "0x0987654321098765432109876543210987654321".to_string(),
1689                    label: Some("Wallet 2".to_string()),
1690                    chain: "ethereum".to_string(),
1691                    balance: "1.0".to_string(),
1692                    usd: Some(2000.0),
1693                    tokens: vec![],
1694                },
1695            ],
1696        };
1697
1698        let md = portfolio_summary_to_markdown(&summary);
1699
1700        // Check total USD
1701        assert!(md.contains("**Total Value (USD):** $5000.00"));
1702
1703        // Check chain USD value
1704        assert!(md.contains("$3000.00"));
1705
1706        // Check address USD values
1707        assert!(md.contains("$3000.00"));
1708        assert!(md.contains("$2000.00"));
1709    }
1710
1711    #[test]
1712    fn test_portfolio_summary_to_markdown_with_tokens() {
1713        let mut balances_by_chain = HashMap::new();
1714        balances_by_chain.insert(
1715            "ethereum".to_string(),
1716            ChainBalance {
1717                native_balance: "1.0".to_string(),
1718                symbol: "ETH".to_string(),
1719                usd: None,
1720            },
1721        );
1722
1723        // Create more than 3 tokens to test truncation
1724        let tokens = vec![
1725            TokenSummary {
1726                contract_address: "0xToken1".to_string(),
1727                balance: "100.0".to_string(),
1728                decimals: 18,
1729                symbol: Some("USDC".to_string()),
1730            },
1731            TokenSummary {
1732                contract_address: "0xToken2".to_string(),
1733                balance: "50.0".to_string(),
1734                decimals: 18,
1735                symbol: Some("DAI".to_string()),
1736            },
1737            TokenSummary {
1738                contract_address: "0xToken3".to_string(),
1739                balance: "25.0".to_string(),
1740                decimals: 18,
1741                symbol: Some("WBTC".to_string()),
1742            },
1743            TokenSummary {
1744                contract_address: "0xToken4".to_string(),
1745                balance: "10.0".to_string(),
1746                decimals: 18,
1747                symbol: Some("UNI".to_string()),
1748            },
1749            TokenSummary {
1750                contract_address: "0xToken5".to_string(),
1751                balance: "5.0".to_string(),
1752                decimals: 18,
1753                symbol: None, // Test token without symbol
1754            },
1755        ];
1756
1757        let summary = PortfolioSummary {
1758            address_count: 1,
1759            balances_by_chain,
1760            total_usd: None,
1761            addresses: vec![AddressSummary {
1762                address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1763                label: Some("Token Wallet".to_string()),
1764                chain: "ethereum".to_string(),
1765                balance: "1.0".to_string(),
1766                usd: None,
1767                tokens,
1768            }],
1769        };
1770
1771        let md = portfolio_summary_to_markdown(&summary);
1772
1773        // Check that first 3 tokens are shown
1774        assert!(md.contains("USDC"));
1775        assert!(md.contains("DAI"));
1776        assert!(md.contains("WBTC"));
1777
1778        // Check truncation indicator (+2 for 5 tokens - 3 shown)
1779        assert!(md.contains("+2"));
1780
1781        // Check that token without symbol uses contract address
1782        // The first 3 tokens have symbols, so we should see USDC, DAI, WBTC
1783        // Token 4 (UNI) and Token 5 (no symbol) should be truncated
1784        // But we need to verify the truncation logic shows "+2"
1785    }
1786
1787    #[test]
1788    fn test_portfolio_summary_to_markdown_empty() {
1789        let summary = PortfolioSummary {
1790            address_count: 0,
1791            balances_by_chain: HashMap::new(),
1792            total_usd: None,
1793            addresses: vec![],
1794        };
1795
1796        let md = portfolio_summary_to_markdown(&summary);
1797
1798        // Check header
1799        assert!(md.contains("# Portfolio Report"));
1800        assert!(md.contains("**Addresses:** 0"));
1801
1802        // Check that chain allocation section exists (even if empty)
1803        assert!(md.contains("Allocation by Chain"));
1804
1805        // Check that addresses section exists (even if empty)
1806        assert!(md.contains("## Addresses"));
1807
1808        // Check footer
1809        assert!(md.contains("Report generated by Scope"));
1810    }
1811}