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 };
158
159 std::fs::write(&args.output, &content)?;
160
161 let report = ExportReport {
162 export_type: "portfolio".to_string(),
163 record_count: portfolio.addresses.len(),
164 output_path: args.output.display().to_string(),
165 format: format.to_string(),
166 exported_at: std::time::SystemTime::now()
167 .duration_since(std::time::UNIX_EPOCH)
168 .unwrap_or_default()
169 .as_secs(),
170 };
171
172 println!(
173 "Exported {} portfolio addresses to {}",
174 report.record_count, report.output_path
175 );
176
177 Ok(())
178}
179
180async fn export_address(
182 address: &str,
183 args: &ExportArgs,
184 format: OutputFormat,
185 clients: &dyn ChainClientFactory,
186) -> Result<()> {
187 let chain = if args.chain == "ethereum" {
189 infer_chain_from_address(address)
190 .unwrap_or("ethereum")
191 .to_string()
192 } else {
193 args.chain.clone()
194 };
195
196 tracing::info!(
197 address = %address,
198 chain = %chain,
199 "Exporting address data"
200 );
201
202 println!("Fetching transactions for {} on {}...", address, chain);
203
204 let client = clients.create_chain_client(&chain)?;
206 let chain_txs = client.get_transactions(address, args.limit).await?;
207
208 let from_ts = args.from.as_deref().and_then(parse_date_to_ts);
210 let to_ts = args.to.as_deref().and_then(parse_date_to_ts);
211
212 let transactions: Vec<TransactionExport> = chain_txs
213 .into_iter()
214 .filter(|tx| {
215 let ts = tx.timestamp.unwrap_or(0);
216 if let Some(from) = from_ts
217 && ts < from
218 {
219 return false;
220 }
221 if let Some(to) = to_ts
222 && ts > to
223 {
224 return false;
225 }
226 true
227 })
228 .map(|tx| TransactionExport {
229 hash: tx.hash,
230 block_number: tx.block_number.unwrap_or(0),
231 timestamp: tx.timestamp.unwrap_or(0),
232 from: tx.from,
233 to: tx.to,
234 value: tx.value,
235 gas_used: tx.gas_used.unwrap_or(0),
236 status: tx.status.unwrap_or(true),
237 })
238 .collect();
239
240 let content = match format {
241 OutputFormat::Json => serde_json::to_string_pretty(&ExportData {
242 address: address.to_string(),
243 chain: chain.clone(),
244 transactions: transactions.clone(),
245 exported_at: std::time::SystemTime::now()
246 .duration_since(std::time::UNIX_EPOCH)
247 .unwrap_or_default()
248 .as_secs(),
249 })?,
250 OutputFormat::Csv => {
251 let mut csv = String::from("hash,block,timestamp,from,to,value,gas_used,status\n");
252 for tx in &transactions {
253 csv.push_str(&format!(
254 "{},{},{},{},{},{},{},{}\n",
255 tx.hash,
256 tx.block_number,
257 tx.timestamp,
258 tx.from,
259 tx.to.as_deref().unwrap_or(""),
260 tx.value,
261 tx.gas_used,
262 tx.status
263 ));
264 }
265 csv
266 }
267 OutputFormat::Table => {
268 return Err(ScopeError::Export(
269 "Table format not supported for file export".to_string(),
270 ));
271 }
272 };
273
274 std::fs::write(&args.output, &content)?;
275
276 let report = ExportReport {
277 export_type: "address".to_string(),
278 record_count: transactions.len(),
279 output_path: args.output.display().to_string(),
280 format: format.to_string(),
281 exported_at: std::time::SystemTime::now()
282 .duration_since(std::time::UNIX_EPOCH)
283 .unwrap_or_default()
284 .as_secs(),
285 };
286
287 println!(
288 "Exported {} transactions to {}",
289 report.record_count, report.output_path
290 );
291
292 Ok(())
293}
294
295fn parse_date_to_ts(date: &str) -> Option<u64> {
297 let parts: Vec<&str> = date.split('-').collect();
298 if parts.len() != 3 {
299 return None;
300 }
301 let year: i32 = parts[0].parse().ok()?;
302 let month: u32 = parts[1].parse().ok()?;
303 let day: u32 = parts[2].parse().ok()?;
304
305 let days_from_epoch = days_since_epoch(year, month, day)?;
309 Some((days_from_epoch as u64) * 86400)
310}
311
312fn days_since_epoch(year: i32, month: u32, day: u32) -> Option<i64> {
314 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
315 return None;
316 }
317
318 let y = if month <= 2 { year - 1 } else { year } as i64;
320 let m = if month <= 2 { month + 9 } else { month - 3 } as i64;
321 let era = if y >= 0 { y } else { y - 399 } / 400;
322 let yoe = (y - era * 400) as u64;
323 let doy = (153 * m as u64 + 2) / 5 + day as u64 - 1;
324 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
325 let days = era * 146097 + doe as i64 - 719468;
326 Some(days)
327}
328
329#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
331pub struct TransactionExport {
332 pub hash: String,
334
335 pub block_number: u64,
337
338 pub timestamp: u64,
340
341 pub from: String,
343
344 pub to: Option<String>,
346
347 pub value: String,
349
350 pub gas_used: u64,
352
353 pub status: bool,
355}
356
357#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
359pub struct ExportData {
360 pub address: String,
362
363 pub chain: String,
365
366 pub transactions: Vec<TransactionExport>,
368
369 pub exported_at: u64,
371}
372
373#[cfg(test)]
378mod tests {
379 use super::*;
380 use tempfile::TempDir;
381
382 #[test]
383 fn test_detect_format_json() {
384 let path = PathBuf::from("output.json");
385 assert_eq!(detect_format(&path), OutputFormat::Json);
386 }
387
388 #[test]
389 fn test_detect_format_csv() {
390 let path = PathBuf::from("output.csv");
391 assert_eq!(detect_format(&path), OutputFormat::Csv);
392 }
393
394 #[test]
395 fn test_detect_format_unknown_defaults_to_json() {
396 let path = PathBuf::from("output.txt");
397 assert_eq!(detect_format(&path), OutputFormat::Json);
398 }
399
400 #[test]
401 fn test_detect_format_no_extension() {
402 let path = PathBuf::from("output");
403 assert_eq!(detect_format(&path), OutputFormat::Json);
404 }
405
406 #[test]
407 fn test_export_args_parsing() {
408 use clap::Parser;
409
410 #[derive(Parser)]
411 struct TestCli {
412 #[command(flatten)]
413 args: ExportArgs,
414 }
415
416 let cli = TestCli::try_parse_from([
417 "test",
418 "--address",
419 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
420 "--output",
421 "output.json",
422 ])
423 .unwrap();
424
425 assert_eq!(
426 cli.args.address,
427 Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string())
428 );
429 assert_eq!(cli.args.output, PathBuf::from("output.json"));
430 assert!(!cli.args.portfolio);
431 }
432
433 #[test]
434 fn test_export_args_portfolio_flag() {
435 use clap::Parser;
436
437 #[derive(Parser)]
438 struct TestCli {
439 #[command(flatten)]
440 args: ExportArgs,
441 }
442
443 let cli =
444 TestCli::try_parse_from(["test", "--portfolio", "--output", "portfolio.json"]).unwrap();
445
446 assert!(cli.args.portfolio);
447 assert!(cli.args.address.is_none());
448 }
449
450 #[test]
451 fn test_export_args_with_all_options() {
452 use clap::Parser;
453
454 #[derive(Parser)]
455 struct TestCli {
456 #[command(flatten)]
457 args: ExportArgs,
458 }
459
460 let cli = TestCli::try_parse_from([
461 "test",
462 "--address",
463 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
464 "--output",
465 "output.csv",
466 "--format",
467 "csv",
468 "--chain",
469 "polygon",
470 "--from",
471 "2024-01-01",
472 "--to",
473 "2024-12-31",
474 "--limit",
475 "500",
476 ])
477 .unwrap();
478
479 assert_eq!(cli.args.chain, "polygon");
480 assert_eq!(cli.args.from, Some("2024-01-01".to_string()));
481 assert_eq!(cli.args.to, Some("2024-12-31".to_string()));
482 assert_eq!(cli.args.limit, 500);
483 assert_eq!(cli.args.format, Some(OutputFormat::Csv));
484 }
485
486 #[test]
487 fn test_export_report_serialization() {
488 let report = ExportReport {
489 export_type: "address".to_string(),
490 record_count: 100,
491 output_path: "/tmp/output.json".to_string(),
492 format: "json".to_string(),
493 exported_at: 1700000000,
494 };
495
496 let json = serde_json::to_string(&report).unwrap();
497 assert!(json.contains("address"));
498 assert!(json.contains("100"));
499 assert!(json.contains("/tmp/output.json"));
500 }
501
502 #[test]
503 fn test_transaction_export_serialization() {
504 let tx = TransactionExport {
505 hash: "0xabc123".to_string(),
506 block_number: 12345,
507 timestamp: 1700000000,
508 from: "0xfrom".to_string(),
509 to: Some("0xto".to_string()),
510 value: "1.5".to_string(),
511 gas_used: 21000,
512 status: true,
513 };
514
515 let json = serde_json::to_string(&tx).unwrap();
516 assert!(json.contains("0xabc123"));
517 assert!(json.contains("12345"));
518 assert!(json.contains("21000"));
519 }
520
521 #[test]
522 fn test_export_data_serialization() {
523 let data = ExportData {
524 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
525 chain: "ethereum".to_string(),
526 transactions: vec![],
527 exported_at: 1700000000,
528 };
529
530 let json = serde_json::to_string(&data).unwrap();
531 assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
532 assert!(json.contains("ethereum"));
533 }
534
535 #[tokio::test]
536 async fn test_export_portfolio_json() {
537 use crate::cli::portfolio::{Portfolio, WatchedAddress};
538
539 let temp_dir = TempDir::new().unwrap();
540 let data_dir = temp_dir.path().to_path_buf();
541 let output_path = temp_dir.path().join("portfolio.json");
542
543 let portfolio = Portfolio {
545 addresses: vec![WatchedAddress {
546 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
547 label: Some("Test".to_string()),
548 chain: "ethereum".to_string(),
549 tags: vec![],
550 added_at: 1700000000,
551 }],
552 };
553 portfolio.save(&data_dir).unwrap();
554
555 let config = Config {
556 portfolio: crate::config::PortfolioConfig {
557 data_dir: Some(data_dir),
558 },
559 ..Default::default()
560 };
561
562 let args = ExportArgs {
563 address: None,
564 portfolio: true,
565 output: output_path.clone(),
566 format: Some(OutputFormat::Json),
567 chain: "ethereum".to_string(),
568 from: None,
569 to: None,
570 limit: 1000,
571 };
572
573 let result = export_portfolio(&args, OutputFormat::Json, &config).await;
574 assert!(result.is_ok());
575 assert!(output_path.exists());
576
577 let content = std::fs::read_to_string(&output_path).unwrap();
578 assert!(content.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
579 }
580
581 #[tokio::test]
582 async fn test_export_portfolio_csv() {
583 use crate::cli::portfolio::{Portfolio, WatchedAddress};
584
585 let temp_dir = TempDir::new().unwrap();
586 let data_dir = temp_dir.path().to_path_buf();
587 let output_path = temp_dir.path().join("portfolio.csv");
588
589 let portfolio = Portfolio {
590 addresses: vec![WatchedAddress {
591 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
592 label: Some("Test Wallet".to_string()),
593 chain: "ethereum".to_string(),
594 tags: vec!["personal".to_string()],
595 added_at: 1700000000,
596 }],
597 };
598 portfolio.save(&data_dir).unwrap();
599
600 let config = Config {
601 portfolio: crate::config::PortfolioConfig {
602 data_dir: Some(data_dir),
603 },
604 ..Default::default()
605 };
606
607 let args = ExportArgs {
608 address: None,
609 portfolio: true,
610 output: output_path.clone(),
611 format: Some(OutputFormat::Csv),
612 chain: "ethereum".to_string(),
613 from: None,
614 to: None,
615 limit: 1000,
616 };
617
618 let result = export_portfolio(&args, OutputFormat::Csv, &config).await;
619 assert!(result.is_ok());
620
621 let content = std::fs::read_to_string(&output_path).unwrap();
622 assert!(content.contains("address,label,chain,tags,added_at"));
623 assert!(content.contains("Test Wallet"));
624 assert!(content.contains("personal"));
625 }
626
627 #[test]
632 fn test_parse_date_to_ts_valid() {
633 let ts = parse_date_to_ts("2024-01-01");
634 assert!(ts.is_some());
635 let ts = ts.unwrap();
636 assert!(ts > 1700000000 && ts < 1710000000);
638 }
639
640 #[test]
641 fn test_parse_date_to_ts_epoch() {
642 let ts = parse_date_to_ts("1970-01-01");
643 assert_eq!(ts, Some(0));
644 }
645
646 #[test]
647 fn test_parse_date_to_ts_invalid_format() {
648 assert!(parse_date_to_ts("not-a-date").is_none());
649 assert!(parse_date_to_ts("2024/01/01").is_none());
650 assert!(parse_date_to_ts("2024-01").is_none());
651 assert!(parse_date_to_ts("").is_none());
652 }
653
654 #[test]
655 fn test_parse_date_to_ts_invalid_values() {
656 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()); }
661
662 #[test]
663 fn test_days_since_epoch_basic() {
664 let days = days_since_epoch(1970, 1, 1);
666 assert_eq!(days, Some(0));
667 }
668
669 #[test]
670 fn test_days_since_epoch_known_date() {
671 let days = days_since_epoch(2000, 1, 1);
673 assert_eq!(days, Some(10957));
674 }
675
676 #[test]
677 fn test_days_since_epoch_invalid_month() {
678 assert!(days_since_epoch(2024, 13, 1).is_none());
679 assert!(days_since_epoch(2024, 0, 1).is_none());
680 }
681
682 #[test]
683 fn test_days_since_epoch_invalid_day() {
684 assert!(days_since_epoch(2024, 1, 0).is_none());
685 assert!(days_since_epoch(2024, 1, 32).is_none());
686 }
687
688 #[tokio::test]
689 async fn test_export_portfolio_table_error() {
690 let temp_dir = TempDir::new().unwrap();
691 let data_dir = temp_dir.path().to_path_buf();
692 let output_path = temp_dir.path().join("output.txt");
693
694 use crate::cli::portfolio::Portfolio;
696 let portfolio = Portfolio { addresses: vec![] };
697 portfolio.save(&data_dir).unwrap();
698
699 let config = Config {
700 portfolio: crate::config::PortfolioConfig {
701 data_dir: Some(data_dir),
702 },
703 ..Default::default()
704 };
705
706 let args = ExportArgs {
707 address: None,
708 portfolio: true,
709 output: output_path,
710 format: Some(OutputFormat::Table),
711 chain: "ethereum".to_string(),
712 from: None,
713 to: None,
714 limit: 1000,
715 };
716
717 let result = export_portfolio(&args, OutputFormat::Table, &config).await;
718 assert!(result.is_err()); }
720
721 #[tokio::test]
722 async fn test_run_no_source_error() {
723 let config = Config::default();
724 let args = ExportArgs {
725 address: None,
726 portfolio: false,
727 output: PathBuf::from("output.json"),
728 format: None,
729 chain: "ethereum".to_string(),
730 from: None,
731 to: None,
732 limit: 1000,
733 };
734
735 let factory = crate::chains::DefaultClientFactory {
736 chains_config: crate::config::ChainsConfig::default(),
737 };
738 let result = run(args, &config, &factory).await;
739 assert!(result.is_err());
740 }
741
742 use crate::chains::mocks::{MockChainClient, MockClientFactory};
747
748 fn mock_factory() -> MockClientFactory {
749 let mut factory = MockClientFactory::new();
750 factory.mock_client = MockChainClient::new("ethereum", "ETH");
751 factory.mock_client.transactions = vec![crate::chains::Transaction {
752 hash: "0xexport1".to_string(),
753 block_number: Some(100),
754 timestamp: Some(1700000000),
755 from: "0xfrom".to_string(),
756 to: Some("0xto".to_string()),
757 value: "1.0".to_string(),
758 gas_limit: 21000,
759 gas_used: Some(21000),
760 gas_price: "20000000000".to_string(),
761 nonce: 0,
762 input: "0x".to_string(),
763 status: Some(true),
764 }];
765 factory
766 }
767
768 #[tokio::test]
769 async fn test_run_export_address_json() {
770 let config = Config::default();
771 let factory = mock_factory();
772 let tmp = tempfile::NamedTempFile::new().unwrap();
773 let args = ExportArgs {
774 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
775 portfolio: false,
776 output: tmp.path().to_path_buf(),
777 format: Some(OutputFormat::Json),
778 chain: "ethereum".to_string(),
779 from: None,
780 to: None,
781 limit: 100,
782 };
783 let result = run(args, &config, &factory).await;
784 assert!(result.is_ok());
785 let content = std::fs::read_to_string(tmp.path()).unwrap();
787 assert!(content.contains("0xexport1"));
788 }
789
790 #[tokio::test]
791 async fn test_run_export_address_csv() {
792 let config = Config::default();
793 let factory = mock_factory();
794 let tmp = tempfile::NamedTempFile::new().unwrap();
795 let args = ExportArgs {
796 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
797 portfolio: false,
798 output: tmp.path().to_path_buf(),
799 format: Some(OutputFormat::Csv),
800 chain: "ethereum".to_string(),
801 from: None,
802 to: None,
803 limit: 100,
804 };
805 let result = run(args, &config, &factory).await;
806 assert!(result.is_ok());
807 let content = std::fs::read_to_string(tmp.path()).unwrap();
808 assert!(content.contains("hash,block,timestamp"));
809 }
810
811 #[tokio::test]
812 async fn test_run_export_with_date_filter() {
813 let config = Config::default();
814 let factory = mock_factory();
815 let tmp = tempfile::NamedTempFile::new().unwrap();
816 let args = ExportArgs {
817 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
818 portfolio: false,
819 output: tmp.path().to_path_buf(),
820 format: Some(OutputFormat::Json),
821 chain: "ethereum".to_string(),
822 from: Some("2023-01-01".to_string()),
823 to: Some("2025-12-31".to_string()),
824 limit: 100,
825 };
826 let result = run(args, &config, &factory).await;
827 assert!(result.is_ok());
828 }
829}