1use crate::chains::{ChainClientFactory, infer_chain_from_address};
20use crate::config::{Config, OutputFormat};
21use crate::error::{Result, ScopeError};
22use clap::Args;
23use std::path::PathBuf;
24
25#[derive(Debug, Clone, Args)]
27pub struct ExportArgs {
28 #[arg(short, long, value_name = "ADDRESS", group = "source")]
30 pub address: Option<String>,
31
32 #[arg(short, long, group = "source")]
34 pub portfolio: bool,
35
36 #[arg(short, long, value_name = "PATH")]
38 pub output: PathBuf,
39
40 #[arg(short, long, value_name = "FORMAT")]
42 pub format: Option<OutputFormat>,
43
44 #[arg(short, long, default_value = "ethereum")]
46 pub chain: String,
47
48 #[arg(long, value_name = "DATE")]
50 pub from: Option<String>,
51
52 #[arg(long, value_name = "DATE")]
54 pub to: Option<String>,
55
56 #[arg(long, default_value = "1000")]
58 pub limit: u32,
59}
60
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct ExportReport {
64 pub export_type: String,
66
67 pub record_count: usize,
69
70 pub output_path: String,
72
73 pub format: String,
75
76 pub exported_at: u64,
78}
79
80pub async fn run(
96 args: ExportArgs,
97 config: &Config,
98 clients: &dyn ChainClientFactory,
99) -> Result<()> {
100 let format = args.format.unwrap_or_else(|| detect_format(&args.output));
102
103 tracing::info!(
104 output = %args.output.display(),
105 format = %format,
106 "Starting export"
107 );
108
109 if args.portfolio {
110 export_portfolio(&args, format, config).await
111 } else if let Some(ref address) = args.address {
112 export_address(address, &args, format, clients).await
113 } else {
114 Err(ScopeError::Export(
115 "Must specify either --address or --portfolio".to_string(),
116 ))
117 }
118}
119
120fn detect_format(path: &std::path::Path) -> OutputFormat {
122 match path.extension().and_then(|e| e.to_str()) {
123 Some("json") => OutputFormat::Json,
124 Some("csv") => OutputFormat::Csv,
125 _ => OutputFormat::Json, }
127}
128
129async fn export_portfolio(args: &ExportArgs, format: OutputFormat, config: &Config) -> Result<()> {
131 use crate::cli::portfolio::Portfolio;
132
133 let data_dir = config.data_dir();
134 let portfolio = Portfolio::load(&data_dir)?;
135
136 let content = match format {
137 OutputFormat::Json => serde_json::to_string_pretty(&portfolio)?,
138 OutputFormat::Csv => {
139 let mut csv = String::from("address,label,chain,tags,added_at\n");
140 for addr in &portfolio.addresses {
141 csv.push_str(&format!(
142 "{},{},{},{},{}\n",
143 addr.address,
144 addr.label.as_deref().unwrap_or(""),
145 addr.chain,
146 addr.tags.join(";"),
147 addr.added_at
148 ));
149 }
150 csv
151 }
152 OutputFormat::Table => {
153 return Err(ScopeError::Export(
154 "Table format not supported for file export".to_string(),
155 ));
156 }
157 OutputFormat::Markdown => {
158 let mut md = "# Portfolio Export\n\n".to_string();
159 md.push_str("| Address | Label | Chain | Tags | Added |\n|---------|-------|-------|------|-------|\n");
160 for addr in &portfolio.addresses {
161 md.push_str(&format!(
162 "| `{}` | {} | {} | {} | {} |\n",
163 addr.address,
164 addr.label.as_deref().unwrap_or("-"),
165 addr.chain,
166 addr.tags.join(", "),
167 addr.added_at
168 ));
169 }
170 md
171 }
172 };
173
174 std::fs::write(&args.output, &content)?;
175
176 let report = ExportReport {
177 export_type: "portfolio".to_string(),
178 record_count: portfolio.addresses.len(),
179 output_path: args.output.display().to_string(),
180 format: format.to_string(),
181 exported_at: std::time::SystemTime::now()
182 .duration_since(std::time::UNIX_EPOCH)
183 .unwrap_or_default()
184 .as_secs(),
185 };
186
187 println!(
188 "Exported {} portfolio addresses to {}",
189 report.record_count, report.output_path
190 );
191
192 Ok(())
193}
194
195async fn export_address(
197 address: &str,
198 args: &ExportArgs,
199 format: OutputFormat,
200 clients: &dyn ChainClientFactory,
201) -> Result<()> {
202 let chain = if args.chain == "ethereum" {
204 infer_chain_from_address(address)
205 .unwrap_or("ethereum")
206 .to_string()
207 } else {
208 args.chain.clone()
209 };
210
211 tracing::info!(
212 address = %address,
213 chain = %chain,
214 "Exporting address data"
215 );
216
217 println!("Fetching transactions for {} on {}...", address, chain);
218
219 let client = clients.create_chain_client(&chain)?;
221 let chain_txs = client.get_transactions(address, args.limit).await?;
222
223 let from_ts = args.from.as_deref().and_then(parse_date_to_ts);
225 let to_ts = args.to.as_deref().and_then(parse_date_to_ts);
226
227 let transactions: Vec<TransactionExport> = chain_txs
228 .into_iter()
229 .filter(|tx| {
230 let ts = tx.timestamp.unwrap_or(0);
231 if let Some(from) = from_ts
232 && ts < from
233 {
234 return false;
235 }
236 if let Some(to) = to_ts
237 && ts > to
238 {
239 return false;
240 }
241 true
242 })
243 .map(|tx| TransactionExport {
244 hash: tx.hash,
245 block_number: tx.block_number.unwrap_or(0),
246 timestamp: tx.timestamp.unwrap_or(0),
247 from: tx.from,
248 to: tx.to,
249 value: tx.value,
250 gas_used: tx.gas_used.unwrap_or(0),
251 status: tx.status.unwrap_or(true),
252 })
253 .collect();
254
255 let content = match format {
256 OutputFormat::Json => serde_json::to_string_pretty(&ExportData {
257 address: address.to_string(),
258 chain: chain.clone(),
259 transactions: transactions.clone(),
260 exported_at: std::time::SystemTime::now()
261 .duration_since(std::time::UNIX_EPOCH)
262 .unwrap_or_default()
263 .as_secs(),
264 })?,
265 OutputFormat::Csv => {
266 let mut csv = String::from("hash,block,timestamp,from,to,value,gas_used,status\n");
267 for tx in &transactions {
268 csv.push_str(&format!(
269 "{},{},{},{},{},{},{},{}\n",
270 tx.hash,
271 tx.block_number,
272 tx.timestamp,
273 tx.from,
274 tx.to.as_deref().unwrap_or(""),
275 tx.value,
276 tx.gas_used,
277 tx.status
278 ));
279 }
280 csv
281 }
282 OutputFormat::Table => {
283 return Err(ScopeError::Export(
284 "Table format not supported for file export".to_string(),
285 ));
286 }
287 OutputFormat::Markdown => {
288 let mut md = format!(
289 "# Transaction Export\n\n**Address:** `{}` \n**Chain:** {} \n**Transactions:** {} \n\n",
290 address,
291 chain,
292 transactions.len()
293 );
294 md.push_str("| Hash | Block | Timestamp | From | To | Value | Gas | Status |\n");
295 md.push_str("|------|-------|-----------|------|----|-------|-----|--------|\n");
296 for tx in &transactions {
297 md.push_str(&format!(
298 "| `{}` | {} | {} | `{}` | `{}` | {} | {} | {} |\n",
299 tx.hash,
300 tx.block_number,
301 tx.timestamp,
302 tx.from,
303 tx.to.as_deref().unwrap_or("-"),
304 tx.value,
305 tx.gas_used,
306 tx.status
307 ));
308 }
309 md
310 }
311 };
312
313 std::fs::write(&args.output, &content)?;
314
315 let report = ExportReport {
316 export_type: "address".to_string(),
317 record_count: transactions.len(),
318 output_path: args.output.display().to_string(),
319 format: format.to_string(),
320 exported_at: std::time::SystemTime::now()
321 .duration_since(std::time::UNIX_EPOCH)
322 .unwrap_or_default()
323 .as_secs(),
324 };
325
326 println!(
327 "Exported {} transactions to {}",
328 report.record_count, report.output_path
329 );
330
331 Ok(())
332}
333
334fn parse_date_to_ts(date: &str) -> Option<u64> {
336 let parts: Vec<&str> = date.split('-').collect();
337 if parts.len() != 3 {
338 return None;
339 }
340 let year: i32 = parts[0].parse().ok()?;
341 let month: u32 = parts[1].parse().ok()?;
342 let day: u32 = parts[2].parse().ok()?;
343
344 let days_from_epoch = days_since_epoch(year, month, day)?;
348 Some((days_from_epoch as u64) * 86400)
349}
350
351fn days_since_epoch(year: i32, month: u32, day: u32) -> Option<i64> {
353 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
354 return None;
355 }
356
357 let y = if month <= 2 { year - 1 } else { year } as i64;
359 let m = if month <= 2 { month + 9 } else { month - 3 } as i64;
360 let era = if y >= 0 { y } else { y - 399 } / 400;
361 let yoe = (y - era * 400) as u64;
362 let doy = (153 * m as u64 + 2) / 5 + day as u64 - 1;
363 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
364 let days = era * 146097 + doe as i64 - 719468;
365 Some(days)
366}
367
368#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
370pub struct TransactionExport {
371 pub hash: String,
373
374 pub block_number: u64,
376
377 pub timestamp: u64,
379
380 pub from: String,
382
383 pub to: Option<String>,
385
386 pub value: String,
388
389 pub gas_used: u64,
391
392 pub status: bool,
394}
395
396#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
398pub struct ExportData {
399 pub address: String,
401
402 pub chain: String,
404
405 pub transactions: Vec<TransactionExport>,
407
408 pub exported_at: u64,
410}
411
412#[cfg(test)]
417mod tests {
418 use super::*;
419 use tempfile::TempDir;
420
421 #[test]
422 fn test_detect_format_json() {
423 let path = PathBuf::from("output.json");
424 assert_eq!(detect_format(&path), OutputFormat::Json);
425 }
426
427 #[test]
428 fn test_detect_format_csv() {
429 let path = PathBuf::from("output.csv");
430 assert_eq!(detect_format(&path), OutputFormat::Csv);
431 }
432
433 #[test]
434 fn test_detect_format_unknown_defaults_to_json() {
435 let path = PathBuf::from("output.txt");
436 assert_eq!(detect_format(&path), OutputFormat::Json);
437 }
438
439 #[test]
440 fn test_detect_format_no_extension() {
441 let path = PathBuf::from("output");
442 assert_eq!(detect_format(&path), OutputFormat::Json);
443 }
444
445 #[test]
446 fn test_export_args_parsing() {
447 use clap::Parser;
448
449 #[derive(Parser)]
450 struct TestCli {
451 #[command(flatten)]
452 args: ExportArgs,
453 }
454
455 let cli = TestCli::try_parse_from([
456 "test",
457 "--address",
458 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
459 "--output",
460 "output.json",
461 ])
462 .unwrap();
463
464 assert_eq!(
465 cli.args.address,
466 Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string())
467 );
468 assert_eq!(cli.args.output, PathBuf::from("output.json"));
469 assert!(!cli.args.portfolio);
470 }
471
472 #[test]
473 fn test_export_args_portfolio_flag() {
474 use clap::Parser;
475
476 #[derive(Parser)]
477 struct TestCli {
478 #[command(flatten)]
479 args: ExportArgs,
480 }
481
482 let cli =
483 TestCli::try_parse_from(["test", "--portfolio", "--output", "portfolio.json"]).unwrap();
484
485 assert!(cli.args.portfolio);
486 assert!(cli.args.address.is_none());
487 }
488
489 #[test]
490 fn test_export_args_with_all_options() {
491 use clap::Parser;
492
493 #[derive(Parser)]
494 struct TestCli {
495 #[command(flatten)]
496 args: ExportArgs,
497 }
498
499 let cli = TestCli::try_parse_from([
500 "test",
501 "--address",
502 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
503 "--output",
504 "output.csv",
505 "--format",
506 "csv",
507 "--chain",
508 "polygon",
509 "--from",
510 "2024-01-01",
511 "--to",
512 "2024-12-31",
513 "--limit",
514 "500",
515 ])
516 .unwrap();
517
518 assert_eq!(cli.args.chain, "polygon");
519 assert_eq!(cli.args.from, Some("2024-01-01".to_string()));
520 assert_eq!(cli.args.to, Some("2024-12-31".to_string()));
521 assert_eq!(cli.args.limit, 500);
522 assert_eq!(cli.args.format, Some(OutputFormat::Csv));
523 }
524
525 #[test]
526 fn test_export_report_serialization() {
527 let report = ExportReport {
528 export_type: "address".to_string(),
529 record_count: 100,
530 output_path: "/tmp/output.json".to_string(),
531 format: "json".to_string(),
532 exported_at: 1700000000,
533 };
534
535 let json = serde_json::to_string(&report).unwrap();
536 assert!(json.contains("address"));
537 assert!(json.contains("100"));
538 assert!(json.contains("/tmp/output.json"));
539 }
540
541 #[test]
542 fn test_transaction_export_serialization() {
543 let tx = TransactionExport {
544 hash: "0xabc123".to_string(),
545 block_number: 12345,
546 timestamp: 1700000000,
547 from: "0xfrom".to_string(),
548 to: Some("0xto".to_string()),
549 value: "1.5".to_string(),
550 gas_used: 21000,
551 status: true,
552 };
553
554 let json = serde_json::to_string(&tx).unwrap();
555 assert!(json.contains("0xabc123"));
556 assert!(json.contains("12345"));
557 assert!(json.contains("21000"));
558 }
559
560 #[test]
561 fn test_export_data_serialization() {
562 let data = ExportData {
563 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
564 chain: "ethereum".to_string(),
565 transactions: vec![],
566 exported_at: 1700000000,
567 };
568
569 let json = serde_json::to_string(&data).unwrap();
570 assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
571 assert!(json.contains("ethereum"));
572 }
573
574 #[tokio::test]
575 async fn test_export_portfolio_json() {
576 use crate::cli::portfolio::{Portfolio, WatchedAddress};
577
578 let temp_dir = TempDir::new().unwrap();
579 let data_dir = temp_dir.path().to_path_buf();
580 let output_path = temp_dir.path().join("portfolio.json");
581
582 let portfolio = Portfolio {
584 addresses: vec![WatchedAddress {
585 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
586 label: Some("Test".to_string()),
587 chain: "ethereum".to_string(),
588 tags: vec![],
589 added_at: 1700000000,
590 }],
591 };
592 portfolio.save(&data_dir).unwrap();
593
594 let config = Config {
595 portfolio: crate::config::PortfolioConfig {
596 data_dir: Some(data_dir),
597 },
598 ..Default::default()
599 };
600
601 let args = ExportArgs {
602 address: None,
603 portfolio: true,
604 output: output_path.clone(),
605 format: Some(OutputFormat::Json),
606 chain: "ethereum".to_string(),
607 from: None,
608 to: None,
609 limit: 1000,
610 };
611
612 let result = export_portfolio(&args, OutputFormat::Json, &config).await;
613 assert!(result.is_ok());
614 assert!(output_path.exists());
615
616 let content = std::fs::read_to_string(&output_path).unwrap();
617 assert!(content.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
618 }
619
620 #[tokio::test]
621 async fn test_export_portfolio_csv() {
622 use crate::cli::portfolio::{Portfolio, WatchedAddress};
623
624 let temp_dir = TempDir::new().unwrap();
625 let data_dir = temp_dir.path().to_path_buf();
626 let output_path = temp_dir.path().join("portfolio.csv");
627
628 let portfolio = Portfolio {
629 addresses: vec![WatchedAddress {
630 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
631 label: Some("Test Wallet".to_string()),
632 chain: "ethereum".to_string(),
633 tags: vec!["personal".to_string()],
634 added_at: 1700000000,
635 }],
636 };
637 portfolio.save(&data_dir).unwrap();
638
639 let config = Config {
640 portfolio: crate::config::PortfolioConfig {
641 data_dir: Some(data_dir),
642 },
643 ..Default::default()
644 };
645
646 let args = ExportArgs {
647 address: None,
648 portfolio: true,
649 output: output_path.clone(),
650 format: Some(OutputFormat::Csv),
651 chain: "ethereum".to_string(),
652 from: None,
653 to: None,
654 limit: 1000,
655 };
656
657 let result = export_portfolio(&args, OutputFormat::Csv, &config).await;
658 assert!(result.is_ok());
659
660 let content = std::fs::read_to_string(&output_path).unwrap();
661 assert!(content.contains("address,label,chain,tags,added_at"));
662 assert!(content.contains("Test Wallet"));
663 assert!(content.contains("personal"));
664 }
665
666 #[test]
671 fn test_parse_date_to_ts_valid() {
672 let ts = parse_date_to_ts("2024-01-01");
673 assert!(ts.is_some());
674 let ts = ts.unwrap();
675 assert!(ts > 1700000000 && ts < 1710000000);
677 }
678
679 #[test]
680 fn test_parse_date_to_ts_epoch() {
681 let ts = parse_date_to_ts("1970-01-01");
682 assert_eq!(ts, Some(0));
683 }
684
685 #[test]
686 fn test_parse_date_to_ts_invalid_format() {
687 assert!(parse_date_to_ts("not-a-date").is_none());
688 assert!(parse_date_to_ts("2024/01/01").is_none());
689 assert!(parse_date_to_ts("2024-01").is_none());
690 assert!(parse_date_to_ts("").is_none());
691 }
692
693 #[test]
694 fn test_parse_date_to_ts_invalid_values() {
695 assert!(parse_date_to_ts("2024-13-01").is_none()); assert!(parse_date_to_ts("2024-00-01").is_none()); assert!(parse_date_to_ts("2024-01-00").is_none()); assert!(parse_date_to_ts("2024-01-32").is_none()); }
700
701 #[test]
702 fn test_days_since_epoch_basic() {
703 let days = days_since_epoch(1970, 1, 1);
705 assert_eq!(days, Some(0));
706 }
707
708 #[test]
709 fn test_days_since_epoch_known_date() {
710 let days = days_since_epoch(2000, 1, 1);
712 assert_eq!(days, Some(10957));
713 }
714
715 #[test]
716 fn test_days_since_epoch_invalid_month() {
717 assert!(days_since_epoch(2024, 13, 1).is_none());
718 assert!(days_since_epoch(2024, 0, 1).is_none());
719 }
720
721 #[test]
722 fn test_days_since_epoch_invalid_day() {
723 assert!(days_since_epoch(2024, 1, 0).is_none());
724 assert!(days_since_epoch(2024, 1, 32).is_none());
725 }
726
727 #[tokio::test]
728 async fn test_export_portfolio_table_error() {
729 let temp_dir = TempDir::new().unwrap();
730 let data_dir = temp_dir.path().to_path_buf();
731 let output_path = temp_dir.path().join("output.txt");
732
733 use crate::cli::portfolio::Portfolio;
735 let portfolio = Portfolio { addresses: vec![] };
736 portfolio.save(&data_dir).unwrap();
737
738 let config = Config {
739 portfolio: crate::config::PortfolioConfig {
740 data_dir: Some(data_dir),
741 },
742 ..Default::default()
743 };
744
745 let args = ExportArgs {
746 address: None,
747 portfolio: true,
748 output: output_path,
749 format: Some(OutputFormat::Table),
750 chain: "ethereum".to_string(),
751 from: None,
752 to: None,
753 limit: 1000,
754 };
755
756 let result = export_portfolio(&args, OutputFormat::Table, &config).await;
757 assert!(result.is_err()); }
759
760 #[tokio::test]
761 async fn test_run_no_source_error() {
762 let config = Config::default();
763 let args = ExportArgs {
764 address: None,
765 portfolio: false,
766 output: PathBuf::from("output.json"),
767 format: None,
768 chain: "ethereum".to_string(),
769 from: None,
770 to: None,
771 limit: 1000,
772 };
773
774 let factory = crate::chains::DefaultClientFactory {
775 chains_config: crate::config::ChainsConfig::default(),
776 };
777 let result = run(args, &config, &factory).await;
778 assert!(result.is_err());
779 }
780
781 use crate::chains::mocks::{MockChainClient, MockClientFactory};
786
787 fn mock_factory() -> MockClientFactory {
788 let mut factory = MockClientFactory::new();
789 factory.mock_client = MockChainClient::new("ethereum", "ETH");
790 factory.mock_client.transactions = vec![crate::chains::Transaction {
791 hash: "0xexport1".to_string(),
792 block_number: Some(100),
793 timestamp: Some(1700000000),
794 from: "0xfrom".to_string(),
795 to: Some("0xto".to_string()),
796 value: "1.0".to_string(),
797 gas_limit: 21000,
798 gas_used: Some(21000),
799 gas_price: "20000000000".to_string(),
800 nonce: 0,
801 input: "0x".to_string(),
802 status: Some(true),
803 }];
804 factory
805 }
806
807 #[tokio::test]
808 async fn test_run_export_address_json() {
809 let config = Config::default();
810 let factory = mock_factory();
811 let tmp = tempfile::NamedTempFile::new().unwrap();
812 let args = ExportArgs {
813 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
814 portfolio: false,
815 output: tmp.path().to_path_buf(),
816 format: Some(OutputFormat::Json),
817 chain: "ethereum".to_string(),
818 from: None,
819 to: None,
820 limit: 100,
821 };
822 let result = run(args, &config, &factory).await;
823 assert!(result.is_ok());
824 let content = std::fs::read_to_string(tmp.path()).unwrap();
826 assert!(content.contains("0xexport1"));
827 }
828
829 #[tokio::test]
830 async fn test_run_export_address_csv() {
831 let config = Config::default();
832 let factory = mock_factory();
833 let tmp = tempfile::NamedTempFile::new().unwrap();
834 let args = ExportArgs {
835 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
836 portfolio: false,
837 output: tmp.path().to_path_buf(),
838 format: Some(OutputFormat::Csv),
839 chain: "ethereum".to_string(),
840 from: None,
841 to: None,
842 limit: 100,
843 };
844 let result = run(args, &config, &factory).await;
845 assert!(result.is_ok());
846 let content = std::fs::read_to_string(tmp.path()).unwrap();
847 assert!(content.contains("hash,block,timestamp"));
848 }
849
850 #[tokio::test]
851 async fn test_run_export_with_date_filter() {
852 let config = Config::default();
853 let factory = mock_factory();
854 let tmp = tempfile::NamedTempFile::new().unwrap();
855 let args = ExportArgs {
856 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
857 portfolio: false,
858 output: tmp.path().to_path_buf(),
859 format: Some(OutputFormat::Json),
860 chain: "ethereum".to_string(),
861 from: Some("2023-01-01".to_string()),
862 to: Some("2025-12-31".to_string()),
863 limit: 100,
864 };
865 let result = run(args, &config, &factory).await;
866 assert!(result.is_ok());
867 }
868}