1use 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#[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 contract_address: 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 '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 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: 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 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 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.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
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 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#[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(), 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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, };
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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 assert!(md.contains("ethereum"));
1648 assert!(md.contains("1.5"));
1649 assert!(md.contains("ETH"));
1650
1651 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 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 assert!(md.contains("**Total Value (USD):** $5000.00"));
1702
1703 assert!(md.contains("$3000.00"));
1705
1706 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 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, },
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 assert!(md.contains("USDC"));
1775 assert!(md.contains("DAI"));
1776 assert!(md.contains("WBTC"));
1777
1778 assert!(md.contains("+2"));
1780
1781 }
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 assert!(md.contains("# Portfolio Report"));
1800 assert!(md.contains("**Addresses:** 0"));
1801
1802 assert!(md.contains("Allocation by Chain"));
1804
1805 assert!(md.contains("## Addresses"));
1807
1808 assert!(md.contains("Report generated by Scope"));
1810 }
1811}