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