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