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