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 let sp = crate::cli::progress::Spinner::new("Exporting data...");
110 let result = if args.portfolio {
111 export_portfolio(&args, format, config).await
112 } else if let Some(ref address) = args.address {
113 export_address(address, &args, format, clients).await
114 } else {
115 Err(ScopeError::Export(
116 "Must specify either --address or --portfolio".to_string(),
117 ))
118 };
119 sp.finish_and_clear();
120 result
121}
122
123fn detect_format(path: &std::path::Path) -> OutputFormat {
125 match path.extension().and_then(|e| e.to_str()) {
126 Some("json") => OutputFormat::Json,
127 Some("csv") => OutputFormat::Csv,
128 _ => OutputFormat::Json, }
130}
131
132async fn export_portfolio(args: &ExportArgs, format: OutputFormat, config: &Config) -> Result<()> {
134 use crate::cli::portfolio::Portfolio;
135
136 let data_dir = config.data_dir();
137 let portfolio = Portfolio::load(&data_dir)?;
138
139 let content = match format {
140 OutputFormat::Json => serde_json::to_string_pretty(&portfolio)?,
141 OutputFormat::Csv => {
142 let mut csv = String::from("address,label,chain,tags,added_at\n");
143 for addr in &portfolio.addresses {
144 csv.push_str(&format!(
145 "{},{},{},{},{}\n",
146 addr.address,
147 addr.label.as_deref().unwrap_or(""),
148 addr.chain,
149 addr.tags.join(";"),
150 addr.added_at
151 ));
152 }
153 csv
154 }
155 OutputFormat::Table => {
156 return Err(ScopeError::Export(
157 "Table format not supported for file export".to_string(),
158 ));
159 }
160 OutputFormat::Markdown => {
161 let mut md = "# Portfolio Export\n\n".to_string();
162 md.push_str("| Address | Label | Chain | Tags | Added |\n|---------|-------|-------|------|-------|\n");
163 for addr in &portfolio.addresses {
164 md.push_str(&format!(
165 "| `{}` | {} | {} | {} | {} |\n",
166 addr.address,
167 addr.label.as_deref().unwrap_or("-"),
168 addr.chain,
169 addr.tags.join(", "),
170 addr.added_at
171 ));
172 }
173 md
174 }
175 };
176
177 std::fs::write(&args.output, &content)?;
178
179 let report = ExportReport {
180 export_type: "portfolio".to_string(),
181 record_count: portfolio.addresses.len(),
182 output_path: args.output.display().to_string(),
183 format: format.to_string(),
184 exported_at: std::time::SystemTime::now()
185 .duration_since(std::time::UNIX_EPOCH)
186 .unwrap_or_default()
187 .as_secs(),
188 };
189
190 println!(
191 "Exported {} portfolio addresses to {}",
192 report.record_count, report.output_path
193 );
194
195 Ok(())
196}
197
198async fn export_address(
200 address: &str,
201 args: &ExportArgs,
202 format: OutputFormat,
203 clients: &dyn ChainClientFactory,
204) -> Result<()> {
205 let chain = if args.chain == "ethereum" {
207 infer_chain_from_address(address)
208 .unwrap_or("ethereum")
209 .to_string()
210 } else {
211 args.chain.clone()
212 };
213
214 tracing::info!(
215 address = %address,
216 chain = %chain,
217 "Exporting address data"
218 );
219
220 println!("Fetching transactions for {} on {}...", address, chain);
221
222 let client = clients.create_chain_client(&chain)?;
224 let chain_txs = client.get_transactions(address, args.limit).await?;
225
226 let from_ts = args.from.as_deref().and_then(parse_date_to_ts);
228 let to_ts = args.to.as_deref().and_then(parse_date_to_ts);
229
230 let transactions: Vec<TransactionExport> = chain_txs
231 .into_iter()
232 .filter(|tx| {
233 let ts = tx.timestamp.unwrap_or(0);
234 if let Some(from) = from_ts
235 && ts < from
236 {
237 return false;
238 }
239 if let Some(to) = to_ts
240 && ts > to
241 {
242 return false;
243 }
244 true
245 })
246 .map(|tx| TransactionExport {
247 hash: tx.hash,
248 block_number: tx.block_number.unwrap_or(0),
249 timestamp: tx.timestamp.unwrap_or(0),
250 from: tx.from,
251 to: tx.to,
252 value: tx.value,
253 gas_used: tx.gas_used.unwrap_or(0),
254 status: tx.status.unwrap_or(true),
255 })
256 .collect();
257
258 let content = match format {
259 OutputFormat::Json => serde_json::to_string_pretty(&ExportData {
260 address: address.to_string(),
261 chain: chain.clone(),
262 transactions: transactions.clone(),
263 exported_at: std::time::SystemTime::now()
264 .duration_since(std::time::UNIX_EPOCH)
265 .unwrap_or_default()
266 .as_secs(),
267 })?,
268 OutputFormat::Csv => {
269 let mut csv = String::from("hash,block,timestamp,from,to,value,gas_used,status\n");
270 for tx in &transactions {
271 csv.push_str(&format!(
272 "{},{},{},{},{},{},{},{}\n",
273 tx.hash,
274 tx.block_number,
275 tx.timestamp,
276 tx.from,
277 tx.to.as_deref().unwrap_or(""),
278 tx.value,
279 tx.gas_used,
280 tx.status
281 ));
282 }
283 csv
284 }
285 OutputFormat::Table => {
286 return Err(ScopeError::Export(
287 "Table format not supported for file export".to_string(),
288 ));
289 }
290 OutputFormat::Markdown => {
291 let mut md = format!(
292 "# Transaction Export\n\n**Address:** `{}` \n**Chain:** {} \n**Transactions:** {} \n\n",
293 address,
294 chain,
295 transactions.len()
296 );
297 md.push_str("| Hash | Block | Timestamp | From | To | Value | Gas | Status |\n");
298 md.push_str("|------|-------|-----------|------|----|-------|-----|--------|\n");
299 for tx in &transactions {
300 md.push_str(&format!(
301 "| `{}` | {} | {} | `{}` | `{}` | {} | {} | {} |\n",
302 tx.hash,
303 tx.block_number,
304 tx.timestamp,
305 tx.from,
306 tx.to.as_deref().unwrap_or("-"),
307 tx.value,
308 tx.gas_used,
309 tx.status
310 ));
311 }
312 md
313 }
314 };
315
316 std::fs::write(&args.output, &content)?;
317
318 let report = ExportReport {
319 export_type: "address".to_string(),
320 record_count: transactions.len(),
321 output_path: args.output.display().to_string(),
322 format: format.to_string(),
323 exported_at: std::time::SystemTime::now()
324 .duration_since(std::time::UNIX_EPOCH)
325 .unwrap_or_default()
326 .as_secs(),
327 };
328
329 println!(
330 "Exported {} transactions to {}",
331 report.record_count, report.output_path
332 );
333
334 Ok(())
335}
336
337fn parse_date_to_ts(date: &str) -> Option<u64> {
339 let parts: Vec<&str> = date.split('-').collect();
340 if parts.len() != 3 {
341 return None;
342 }
343 let year: i32 = parts[0].parse().ok()?;
344 let month: u32 = parts[1].parse().ok()?;
345 let day: u32 = parts[2].parse().ok()?;
346
347 let days_from_epoch = days_since_epoch(year, month, day)?;
351 Some((days_from_epoch as u64) * 86400)
352}
353
354fn days_since_epoch(year: i32, month: u32, day: u32) -> Option<i64> {
356 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
357 return None;
358 }
359
360 let y = if month <= 2 { year - 1 } else { year } as i64;
362 let m = if month <= 2 { month + 9 } else { month - 3 } as i64;
363 let era = if y >= 0 { y } else { y - 399 } / 400;
364 let yoe = (y - era * 400) as u64;
365 let doy = (153 * m as u64 + 2) / 5 + day as u64 - 1;
366 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
367 let days = era * 146097 + doe as i64 - 719468;
368 Some(days)
369}
370
371#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
373pub struct TransactionExport {
374 pub hash: String,
376
377 pub block_number: u64,
379
380 pub timestamp: u64,
382
383 pub from: String,
385
386 pub to: Option<String>,
388
389 pub value: String,
391
392 pub gas_used: u64,
394
395 pub status: bool,
397}
398
399#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
401pub struct ExportData {
402 pub address: String,
404
405 pub chain: String,
407
408 pub transactions: Vec<TransactionExport>,
410
411 pub exported_at: u64,
413}
414
415#[cfg(test)]
420mod tests {
421 use super::*;
422 use tempfile::TempDir;
423
424 #[test]
425 fn test_detect_format_json() {
426 let path = PathBuf::from("output.json");
427 assert_eq!(detect_format(&path), OutputFormat::Json);
428 }
429
430 #[test]
431 fn test_detect_format_csv() {
432 let path = PathBuf::from("output.csv");
433 assert_eq!(detect_format(&path), OutputFormat::Csv);
434 }
435
436 #[test]
437 fn test_detect_format_unknown_defaults_to_json() {
438 let path = PathBuf::from("output.txt");
439 assert_eq!(detect_format(&path), OutputFormat::Json);
440 }
441
442 #[test]
443 fn test_detect_format_no_extension() {
444 let path = PathBuf::from("output");
445 assert_eq!(detect_format(&path), OutputFormat::Json);
446 }
447
448 #[test]
449 fn test_export_args_parsing() {
450 use clap::Parser;
451
452 #[derive(Parser)]
453 struct TestCli {
454 #[command(flatten)]
455 args: ExportArgs,
456 }
457
458 let cli = TestCli::try_parse_from([
459 "test",
460 "--address",
461 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
462 "--output",
463 "output.json",
464 ])
465 .unwrap();
466
467 assert_eq!(
468 cli.args.address,
469 Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string())
470 );
471 assert_eq!(cli.args.output, PathBuf::from("output.json"));
472 assert!(!cli.args.portfolio);
473 }
474
475 #[test]
476 fn test_export_args_portfolio_flag() {
477 use clap::Parser;
478
479 #[derive(Parser)]
480 struct TestCli {
481 #[command(flatten)]
482 args: ExportArgs,
483 }
484
485 let cli =
486 TestCli::try_parse_from(["test", "--portfolio", "--output", "portfolio.json"]).unwrap();
487
488 assert!(cli.args.portfolio);
489 assert!(cli.args.address.is_none());
490 }
491
492 #[test]
493 fn test_export_args_with_all_options() {
494 use clap::Parser;
495
496 #[derive(Parser)]
497 struct TestCli {
498 #[command(flatten)]
499 args: ExportArgs,
500 }
501
502 let cli = TestCli::try_parse_from([
503 "test",
504 "--address",
505 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
506 "--output",
507 "output.csv",
508 "--format",
509 "csv",
510 "--chain",
511 "polygon",
512 "--from",
513 "2024-01-01",
514 "--to",
515 "2024-12-31",
516 "--limit",
517 "500",
518 ])
519 .unwrap();
520
521 assert_eq!(cli.args.chain, "polygon");
522 assert_eq!(cli.args.from, Some("2024-01-01".to_string()));
523 assert_eq!(cli.args.to, Some("2024-12-31".to_string()));
524 assert_eq!(cli.args.limit, 500);
525 assert_eq!(cli.args.format, Some(OutputFormat::Csv));
526 }
527
528 #[test]
529 fn test_export_report_serialization() {
530 let report = ExportReport {
531 export_type: "address".to_string(),
532 record_count: 100,
533 output_path: "/tmp/output.json".to_string(),
534 format: "json".to_string(),
535 exported_at: 1700000000,
536 };
537
538 let json = serde_json::to_string(&report).unwrap();
539 assert!(json.contains("address"));
540 assert!(json.contains("100"));
541 assert!(json.contains("/tmp/output.json"));
542 }
543
544 #[test]
545 fn test_transaction_export_serialization() {
546 let tx = TransactionExport {
547 hash: "0xabc123".to_string(),
548 block_number: 12345,
549 timestamp: 1700000000,
550 from: "0xfrom".to_string(),
551 to: Some("0xto".to_string()),
552 value: "1.5".to_string(),
553 gas_used: 21000,
554 status: true,
555 };
556
557 let json = serde_json::to_string(&tx).unwrap();
558 assert!(json.contains("0xabc123"));
559 assert!(json.contains("12345"));
560 assert!(json.contains("21000"));
561 }
562
563 #[test]
564 fn test_export_data_serialization() {
565 let data = ExportData {
566 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
567 chain: "ethereum".to_string(),
568 transactions: vec![],
569 exported_at: 1700000000,
570 };
571
572 let json = serde_json::to_string(&data).unwrap();
573 assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
574 assert!(json.contains("ethereum"));
575 }
576
577 #[tokio::test]
578 async fn test_export_portfolio_json() {
579 use crate::cli::portfolio::{Portfolio, WatchedAddress};
580
581 let temp_dir = TempDir::new().unwrap();
582 let data_dir = temp_dir.path().to_path_buf();
583 let output_path = temp_dir.path().join("portfolio.json");
584
585 let portfolio = Portfolio {
587 addresses: vec![WatchedAddress {
588 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
589 label: Some("Test".to_string()),
590 chain: "ethereum".to_string(),
591 tags: vec![],
592 added_at: 1700000000,
593 }],
594 };
595 portfolio.save(&data_dir).unwrap();
596
597 let config = Config {
598 portfolio: crate::config::PortfolioConfig {
599 data_dir: Some(data_dir),
600 },
601 ..Default::default()
602 };
603
604 let args = ExportArgs {
605 address: None,
606 portfolio: true,
607 output: output_path.clone(),
608 format: Some(OutputFormat::Json),
609 chain: "ethereum".to_string(),
610 from: None,
611 to: None,
612 limit: 1000,
613 };
614
615 let result = export_portfolio(&args, OutputFormat::Json, &config).await;
616 assert!(result.is_ok());
617 assert!(output_path.exists());
618
619 let content = std::fs::read_to_string(&output_path).unwrap();
620 assert!(content.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
621 }
622
623 #[tokio::test]
624 async fn test_export_portfolio_csv() {
625 use crate::cli::portfolio::{Portfolio, WatchedAddress};
626
627 let temp_dir = TempDir::new().unwrap();
628 let data_dir = temp_dir.path().to_path_buf();
629 let output_path = temp_dir.path().join("portfolio.csv");
630
631 let portfolio = Portfolio {
632 addresses: vec![WatchedAddress {
633 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
634 label: Some("Test Wallet".to_string()),
635 chain: "ethereum".to_string(),
636 tags: vec!["personal".to_string()],
637 added_at: 1700000000,
638 }],
639 };
640 portfolio.save(&data_dir).unwrap();
641
642 let config = Config {
643 portfolio: crate::config::PortfolioConfig {
644 data_dir: Some(data_dir),
645 },
646 ..Default::default()
647 };
648
649 let args = ExportArgs {
650 address: None,
651 portfolio: true,
652 output: output_path.clone(),
653 format: Some(OutputFormat::Csv),
654 chain: "ethereum".to_string(),
655 from: None,
656 to: None,
657 limit: 1000,
658 };
659
660 let result = export_portfolio(&args, OutputFormat::Csv, &config).await;
661 assert!(result.is_ok());
662
663 let content = std::fs::read_to_string(&output_path).unwrap();
664 assert!(content.contains("address,label,chain,tags,added_at"));
665 assert!(content.contains("Test Wallet"));
666 assert!(content.contains("personal"));
667 }
668
669 #[tokio::test]
670 async fn test_export_portfolio_markdown() {
671 use crate::cli::portfolio::{Portfolio, WatchedAddress};
672
673 let temp_dir = TempDir::new().unwrap();
674 let data_dir = temp_dir.path().to_path_buf();
675 let output_path = temp_dir.path().join("portfolio.md");
676
677 let portfolio = Portfolio {
678 addresses: vec![WatchedAddress {
679 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
680 label: Some("Test Wallet".to_string()),
681 chain: "ethereum".to_string(),
682 tags: vec!["personal".to_string(), "trading".to_string()],
683 added_at: 1700000000,
684 }],
685 };
686 portfolio.save(&data_dir).unwrap();
687
688 let config = Config {
689 portfolio: crate::config::PortfolioConfig {
690 data_dir: Some(data_dir),
691 },
692 ..Default::default()
693 };
694
695 let args = ExportArgs {
696 address: None,
697 portfolio: true,
698 output: output_path.clone(),
699 format: Some(OutputFormat::Markdown),
700 chain: "ethereum".to_string(),
701 from: None,
702 to: None,
703 limit: 1000,
704 };
705
706 let result = export_portfolio(&args, OutputFormat::Markdown, &config).await;
707 assert!(result.is_ok());
708 assert!(output_path.exists());
709
710 let content = std::fs::read_to_string(&output_path).unwrap();
711 assert!(content.contains("# Portfolio Export"));
712 assert!(content.contains("| Address | Label | Chain | Tags | Added |"));
713 assert!(content.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
714 assert!(content.contains("Test Wallet"));
715 assert!(content.contains("personal, trading"));
716 }
717
718 #[test]
723 fn test_parse_date_to_ts_valid() {
724 let ts = parse_date_to_ts("2024-01-01");
725 assert!(ts.is_some());
726 let ts = ts.unwrap();
727 assert!(ts > 1700000000 && ts < 1710000000);
729 }
730
731 #[test]
732 fn test_parse_date_to_ts_epoch() {
733 let ts = parse_date_to_ts("1970-01-01");
734 assert_eq!(ts, Some(0));
735 }
736
737 #[test]
738 fn test_parse_date_to_ts_invalid_format() {
739 assert!(parse_date_to_ts("not-a-date").is_none());
740 assert!(parse_date_to_ts("2024/01/01").is_none());
741 assert!(parse_date_to_ts("2024-01").is_none());
742 assert!(parse_date_to_ts("").is_none());
743 }
744
745 #[test]
746 fn test_parse_date_to_ts_invalid_values() {
747 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()); }
752
753 #[test]
754 fn test_days_since_epoch_basic() {
755 let days = days_since_epoch(1970, 1, 1);
757 assert_eq!(days, Some(0));
758 }
759
760 #[test]
761 fn test_days_since_epoch_known_date() {
762 let days = days_since_epoch(2000, 1, 1);
764 assert_eq!(days, Some(10957));
765 }
766
767 #[test]
768 fn test_days_since_epoch_invalid_month() {
769 assert!(days_since_epoch(2024, 13, 1).is_none());
770 assert!(days_since_epoch(2024, 0, 1).is_none());
771 }
772
773 #[test]
774 fn test_days_since_epoch_invalid_day() {
775 assert!(days_since_epoch(2024, 1, 0).is_none());
776 assert!(days_since_epoch(2024, 1, 32).is_none());
777 }
778
779 #[tokio::test]
780 async fn test_export_portfolio_table_error() {
781 let temp_dir = TempDir::new().unwrap();
782 let data_dir = temp_dir.path().to_path_buf();
783 let output_path = temp_dir.path().join("output.txt");
784
785 use crate::cli::portfolio::Portfolio;
787 let portfolio = Portfolio { addresses: vec![] };
788 portfolio.save(&data_dir).unwrap();
789
790 let config = Config {
791 portfolio: crate::config::PortfolioConfig {
792 data_dir: Some(data_dir),
793 },
794 ..Default::default()
795 };
796
797 let args = ExportArgs {
798 address: None,
799 portfolio: true,
800 output: output_path,
801 format: Some(OutputFormat::Table),
802 chain: "ethereum".to_string(),
803 from: None,
804 to: None,
805 limit: 1000,
806 };
807
808 let result = export_portfolio(&args, OutputFormat::Table, &config).await;
809 assert!(result.is_err()); }
811
812 #[tokio::test]
813 async fn test_run_no_source_error() {
814 let config = Config::default();
815 let args = ExportArgs {
816 address: None,
817 portfolio: false,
818 output: PathBuf::from("output.json"),
819 format: None,
820 chain: "ethereum".to_string(),
821 from: None,
822 to: None,
823 limit: 1000,
824 };
825
826 let factory = crate::chains::DefaultClientFactory {
827 chains_config: crate::config::ChainsConfig::default(),
828 };
829 let result = run(args, &config, &factory).await;
830 assert!(result.is_err());
831 }
832
833 use crate::chains::mocks::{MockChainClient, MockClientFactory};
838
839 fn mock_factory() -> MockClientFactory {
840 let mut factory = MockClientFactory::new();
841 factory.mock_client = MockChainClient::new("ethereum", "ETH");
842 factory.mock_client.transactions = vec![crate::chains::Transaction {
843 hash: "0xexport1".to_string(),
844 block_number: Some(100),
845 timestamp: Some(1700000000),
846 from: "0xfrom".to_string(),
847 to: Some("0xto".to_string()),
848 value: "1.0".to_string(),
849 gas_limit: 21000,
850 gas_used: Some(21000),
851 gas_price: "20000000000".to_string(),
852 nonce: 0,
853 input: "0x".to_string(),
854 status: Some(true),
855 }];
856 factory
857 }
858
859 #[tokio::test]
860 async fn test_run_export_address_json() {
861 let config = Config::default();
862 let factory = mock_factory();
863 let tmp = tempfile::NamedTempFile::new().unwrap();
864 let args = ExportArgs {
865 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
866 portfolio: false,
867 output: tmp.path().to_path_buf(),
868 format: Some(OutputFormat::Json),
869 chain: "ethereum".to_string(),
870 from: None,
871 to: None,
872 limit: 100,
873 };
874 let result = run(args, &config, &factory).await;
875 assert!(result.is_ok());
876 let content = std::fs::read_to_string(tmp.path()).unwrap();
878 assert!(content.contains("0xexport1"));
879 }
880
881 #[tokio::test]
882 async fn test_run_export_address_csv() {
883 let config = Config::default();
884 let factory = mock_factory();
885 let tmp = tempfile::NamedTempFile::new().unwrap();
886 let args = ExportArgs {
887 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
888 portfolio: false,
889 output: tmp.path().to_path_buf(),
890 format: Some(OutputFormat::Csv),
891 chain: "ethereum".to_string(),
892 from: None,
893 to: None,
894 limit: 100,
895 };
896 let result = run(args, &config, &factory).await;
897 assert!(result.is_ok());
898 let content = std::fs::read_to_string(tmp.path()).unwrap();
899 assert!(content.contains("hash,block,timestamp"));
900 }
901
902 #[tokio::test]
903 async fn test_run_export_address_non_ethereum_chain() {
904 let config = Config::default();
905 let mut factory = MockClientFactory::new();
906 factory.mock_client = MockChainClient::new("polygon", "MATIC");
907 factory.mock_client.transactions = vec![crate::chains::Transaction {
908 hash: "0xpolygon".to_string(),
909 block_number: Some(200),
910 timestamp: Some(1700000000),
911 from: "0xfrom".to_string(),
912 to: Some("0xto".to_string()),
913 value: "2.0".to_string(),
914 gas_limit: 21000,
915 gas_used: Some(21000),
916 gas_price: "20000000000".to_string(),
917 nonce: 0,
918 input: "0x".to_string(),
919 status: Some(true),
920 }];
921 let tmp = tempfile::NamedTempFile::new().unwrap();
922 let args = ExportArgs {
923 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
924 portfolio: false,
925 output: tmp.path().to_path_buf(),
926 format: Some(OutputFormat::Json),
927 chain: "polygon".to_string(), from: None,
929 to: None,
930 limit: 100,
931 };
932 let result = run(args, &config, &factory).await;
933 assert!(result.is_ok());
934 let content = std::fs::read_to_string(tmp.path()).unwrap();
935 assert!(content.contains("polygon"));
936 assert!(content.contains("0xpolygon"));
937 }
938
939 #[tokio::test]
940 async fn test_run_export_with_date_filter() {
941 let config = Config::default();
942 let factory = mock_factory();
943 let tmp = tempfile::NamedTempFile::new().unwrap();
944 let args = ExportArgs {
945 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
946 portfolio: false,
947 output: tmp.path().to_path_buf(),
948 format: Some(OutputFormat::Json),
949 chain: "ethereum".to_string(),
950 from: Some("2023-01-01".to_string()),
951 to: Some("2025-12-31".to_string()),
952 limit: 100,
953 };
954 let result = run(args, &config, &factory).await;
955 assert!(result.is_ok());
956 }
957
958 #[tokio::test]
959 async fn test_run_export_address_markdown() {
960 let config = Config::default();
961 let factory = mock_factory();
962 let tmp = tempfile::NamedTempFile::new().unwrap();
963 let args = ExportArgs {
964 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
965 portfolio: false,
966 output: tmp.path().to_path_buf(),
967 format: Some(OutputFormat::Markdown),
968 chain: "ethereum".to_string(),
969 from: None,
970 to: None,
971 limit: 100,
972 };
973 let result = run(args, &config, &factory).await;
974 assert!(result.is_ok());
975 let content = std::fs::read_to_string(tmp.path()).unwrap();
976 assert!(content.contains("# Transaction Export"));
977 assert!(
978 content.contains("| Hash | Block | Timestamp | From | To | Value | Gas | Status |")
979 );
980 assert!(content.contains("0xexport1"));
981 }
982
983 #[tokio::test]
984 async fn test_run_export_address_table_error() {
985 let config = Config::default();
986 let factory = mock_factory();
987 let tmp = tempfile::NamedTempFile::new().unwrap();
988 let args = ExportArgs {
989 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
990 portfolio: false,
991 output: tmp.path().to_path_buf(),
992 format: Some(OutputFormat::Table),
993 chain: "ethereum".to_string(),
994 from: None,
995 to: None,
996 limit: 100,
997 };
998 let result = run(args, &config, &factory).await;
999 assert!(result.is_err()); }
1001
1002 #[tokio::test]
1003 async fn test_run_export_address_with_date_filter_before() {
1004 let config = Config::default();
1005 let mut factory = MockClientFactory::new();
1006 factory.mock_client = MockChainClient::new("ethereum", "ETH");
1007 factory.mock_client.transactions = vec![crate::chains::Transaction {
1009 hash: "0xbefore".to_string(),
1010 block_number: Some(100),
1011 timestamp: Some(1690000000), from: "0xfrom".to_string(),
1013 to: Some("0xto".to_string()),
1014 value: "1.0".to_string(),
1015 gas_limit: 21000,
1016 gas_used: Some(21000),
1017 gas_price: "20000000000".to_string(),
1018 nonce: 0,
1019 input: "0x".to_string(),
1020 status: Some(true),
1021 }];
1022 let tmp = tempfile::NamedTempFile::new().unwrap();
1023 let args = ExportArgs {
1024 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1025 portfolio: false,
1026 output: tmp.path().to_path_buf(),
1027 format: Some(OutputFormat::Json),
1028 chain: "ethereum".to_string(),
1029 from: Some("2024-01-01".to_string()), to: None,
1031 limit: 100,
1032 };
1033 let result = run(args, &config, &factory).await;
1034 assert!(result.is_ok());
1035 let content = std::fs::read_to_string(tmp.path()).unwrap();
1036 assert!(!content.contains("0xbefore"));
1038 }
1039
1040 #[tokio::test]
1041 async fn test_run_export_address_with_date_filter_after() {
1042 let config = Config::default();
1043 let mut factory = MockClientFactory::new();
1044 factory.mock_client = MockChainClient::new("ethereum", "ETH");
1045 factory.mock_client.transactions = vec![crate::chains::Transaction {
1047 hash: "0xafter".to_string(),
1048 block_number: Some(100),
1049 timestamp: Some(1800000000), from: "0xfrom".to_string(),
1051 to: Some("0xto".to_string()),
1052 value: "1.0".to_string(),
1053 gas_limit: 21000,
1054 gas_used: Some(21000),
1055 gas_price: "20000000000".to_string(),
1056 nonce: 0,
1057 input: "0x".to_string(),
1058 status: Some(true),
1059 }];
1060 let tmp = tempfile::NamedTempFile::new().unwrap();
1061 let args = ExportArgs {
1062 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1063 portfolio: false,
1064 output: tmp.path().to_path_buf(),
1065 format: Some(OutputFormat::Json),
1066 chain: "ethereum".to_string(),
1067 from: None,
1068 to: Some("2025-12-31".to_string()), limit: 100,
1070 };
1071 let result = run(args, &config, &factory).await;
1072 assert!(result.is_ok());
1073 let content = std::fs::read_to_string(tmp.path()).unwrap();
1074 assert!(!content.contains("0xafter"));
1076 }
1077
1078 #[test]
1083 fn test_export_args_debug() {
1084 let args = ExportArgs {
1085 address: Some("0xtest".to_string()),
1086 portfolio: false,
1087 output: PathBuf::from("test.json"),
1088 format: Some(OutputFormat::Json),
1089 chain: "ethereum".to_string(),
1090 from: None,
1091 to: None,
1092 limit: 100,
1093 };
1094 let debug_str = format!("{:?}", args);
1095 assert!(debug_str.contains("ExportArgs"));
1096 assert!(debug_str.contains("0xtest"));
1097 }
1098
1099 #[test]
1100 fn test_export_report_debug() {
1101 let report = ExportReport {
1102 export_type: "address".to_string(),
1103 record_count: 42,
1104 output_path: "/tmp/test.json".to_string(),
1105 format: "json".to_string(),
1106 exported_at: 1700000000,
1107 };
1108 let debug_str = format!("{:?}", report);
1109 assert!(debug_str.contains("ExportReport"));
1110 assert!(debug_str.contains("address"));
1111 assert!(debug_str.contains("42"));
1112 }
1113
1114 #[test]
1115 fn test_transaction_export_debug() {
1116 let tx = TransactionExport {
1117 hash: "0xabc123".to_string(),
1118 block_number: 12345,
1119 timestamp: 1700000000,
1120 from: "0xfrom".to_string(),
1121 to: Some("0xto".to_string()),
1122 value: "1.5".to_string(),
1123 gas_used: 21000,
1124 status: true,
1125 };
1126 let debug_str = format!("{:?}", tx);
1127 assert!(debug_str.contains("TransactionExport"));
1128 assert!(debug_str.contains("0xabc123"));
1129 }
1130
1131 #[test]
1132 fn test_transaction_export_debug_no_to() {
1133 let tx = TransactionExport {
1134 hash: "0xcreate".to_string(),
1135 block_number: 100,
1136 timestamp: 1700000000,
1137 from: "0xdeployer".to_string(),
1138 to: None,
1139 value: "0".to_string(),
1140 gas_used: 500000,
1141 status: true,
1142 };
1143 let debug_str = format!("{:?}", tx);
1144 assert!(debug_str.contains("TransactionExport"));
1145 assert!(debug_str.contains("0xcreate"));
1146 }
1147
1148 #[test]
1149 fn test_export_data_debug() {
1150 let data = ExportData {
1151 address: "0xtest".to_string(),
1152 chain: "ethereum".to_string(),
1153 transactions: vec![],
1154 exported_at: 1700000000,
1155 };
1156 let debug_str = format!("{:?}", data);
1157 assert!(debug_str.contains("ExportData"));
1158 assert!(debug_str.contains("0xtest"));
1159 assert!(debug_str.contains("ethereum"));
1160 }
1161
1162 #[test]
1163 fn test_export_data_debug_with_transactions() {
1164 let data = ExportData {
1165 address: "0xtest".to_string(),
1166 chain: "ethereum".to_string(),
1167 transactions: vec![TransactionExport {
1168 hash: "0xabc".to_string(),
1169 block_number: 1,
1170 timestamp: 0,
1171 from: "0x1".to_string(),
1172 to: Some("0x2".to_string()),
1173 value: "0".to_string(),
1174 gas_used: 21000,
1175 status: true,
1176 }],
1177 exported_at: 1700000000,
1178 };
1179 let debug_str = format!("{:?}", data);
1180 assert!(debug_str.contains("ExportData"));
1181 assert!(debug_str.contains("0xabc"));
1182 }
1183
1184 #[test]
1189 fn test_detect_format_markdown() {
1190 let path = PathBuf::from("output.md");
1191 assert_eq!(detect_format(&path), OutputFormat::Json);
1193 }
1194
1195 #[test]
1196 fn test_detect_format_txt() {
1197 let path = PathBuf::from("output.txt");
1198 assert_eq!(detect_format(&path), OutputFormat::Json);
1199 }
1200
1201 #[test]
1202 fn test_parse_date_to_ts_future_date() {
1203 let ts = parse_date_to_ts("2100-01-01");
1204 assert!(ts.is_some());
1205 let ts = ts.unwrap();
1206 assert!(ts > 4000000000);
1208 }
1209
1210 #[test]
1211 fn test_parse_date_to_ts_leap_year() {
1212 let ts = parse_date_to_ts("2024-02-29");
1213 assert!(ts.is_some());
1214 }
1215
1216 #[test]
1217 fn test_parse_date_to_ts_non_leap_year_feb_29() {
1218 let ts = parse_date_to_ts("2023-02-29");
1221 let _ = ts;
1224 }
1225
1226 #[test]
1227 fn test_days_since_epoch_leap_year() {
1228 let days = days_since_epoch(2024, 2, 29);
1229 assert!(days.is_some());
1230 }
1231
1232 #[test]
1233 fn test_days_since_epoch_year_before_epoch() {
1234 let days = days_since_epoch(1969, 12, 31);
1235 assert!(days.is_some());
1236 assert!(days.unwrap() < 0);
1237 }
1238
1239 #[test]
1240 fn test_days_since_epoch_future_year() {
1241 let days = days_since_epoch(2100, 1, 1);
1242 assert!(days.is_some());
1243 assert!(days.unwrap() > 0);
1244 }
1245}