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 http: std::sync::Arc<dyn crate::http::HttpClient> =
859 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
860 let factory = crate::chains::DefaultClientFactory {
861 chains_config: crate::config::ChainsConfig::default(),
862 http,
863 };
864 let result = run(args, &config, &factory).await;
865 assert!(result.is_err());
866 }
867
868 use crate::chains::mocks::{MockChainClient, MockClientFactory};
873
874 fn mock_factory() -> MockClientFactory {
875 let mut factory = MockClientFactory::new();
876 factory.mock_client = MockChainClient::new("ethereum", "ETH");
877 factory.mock_client.transactions = vec![crate::chains::Transaction {
878 hash: "0xexport1".to_string(),
879 block_number: Some(100),
880 timestamp: Some(1700000000),
881 from: "0xfrom".to_string(),
882 to: Some("0xto".to_string()),
883 value: "1.0".to_string(),
884 gas_limit: 21000,
885 gas_used: Some(21000),
886 gas_price: "20000000000".to_string(),
887 nonce: 0,
888 input: "0x".to_string(),
889 status: Some(true),
890 }];
891 factory
892 }
893
894 #[tokio::test]
895 async fn test_run_export_address_json() {
896 let config = Config::default();
897 let factory = mock_factory();
898 let tmp = tempfile::NamedTempFile::new().unwrap();
899 let args = ExportArgs {
900 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
901 address_book: false,
902 output: tmp.path().to_path_buf(),
903 format: Some(OutputFormat::Json),
904 chain: "ethereum".to_string(),
905 from: None,
906 to: None,
907 limit: 100,
908 };
909 let result = run(args, &config, &factory).await;
910 assert!(result.is_ok());
911 let content = std::fs::read_to_string(tmp.path()).unwrap();
913 assert!(content.contains("0xexport1"));
914 }
915
916 #[tokio::test]
917 async fn test_run_export_address_csv() {
918 let config = Config::default();
919 let factory = mock_factory();
920 let tmp = tempfile::NamedTempFile::new().unwrap();
921 let args = ExportArgs {
922 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
923 address_book: false,
924 output: tmp.path().to_path_buf(),
925 format: Some(OutputFormat::Csv),
926 chain: "ethereum".to_string(),
927 from: None,
928 to: None,
929 limit: 100,
930 };
931 let result = run(args, &config, &factory).await;
932 assert!(result.is_ok());
933 let content = std::fs::read_to_string(tmp.path()).unwrap();
934 assert!(content.contains("hash,block,timestamp"));
935 }
936
937 #[tokio::test]
938 async fn test_run_export_address_non_ethereum_chain() {
939 let config = Config::default();
940 let mut factory = MockClientFactory::new();
941 factory.mock_client = MockChainClient::new("polygon", "MATIC");
942 factory.mock_client.transactions = vec![crate::chains::Transaction {
943 hash: "0xpolygon".to_string(),
944 block_number: Some(200),
945 timestamp: Some(1700000000),
946 from: "0xfrom".to_string(),
947 to: Some("0xto".to_string()),
948 value: "2.0".to_string(),
949 gas_limit: 21000,
950 gas_used: Some(21000),
951 gas_price: "20000000000".to_string(),
952 nonce: 0,
953 input: "0x".to_string(),
954 status: Some(true),
955 }];
956 let tmp = tempfile::NamedTempFile::new().unwrap();
957 let args = ExportArgs {
958 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
959 address_book: false,
960 output: tmp.path().to_path_buf(),
961 format: Some(OutputFormat::Json),
962 chain: "polygon".to_string(), from: None,
964 to: None,
965 limit: 100,
966 };
967 let result = run(args, &config, &factory).await;
968 assert!(result.is_ok());
969 let content = std::fs::read_to_string(tmp.path()).unwrap();
970 assert!(content.contains("polygon"));
971 assert!(content.contains("0xpolygon"));
972 }
973
974 #[tokio::test]
975 async fn test_run_export_with_date_filter() {
976 let config = Config::default();
977 let factory = mock_factory();
978 let tmp = tempfile::NamedTempFile::new().unwrap();
979 let args = ExportArgs {
980 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
981 address_book: false,
982 output: tmp.path().to_path_buf(),
983 format: Some(OutputFormat::Json),
984 chain: "ethereum".to_string(),
985 from: Some("2023-01-01".to_string()),
986 to: Some("2025-12-31".to_string()),
987 limit: 100,
988 };
989 let result = run(args, &config, &factory).await;
990 assert!(result.is_ok());
991 }
992
993 #[tokio::test]
994 async fn test_run_export_address_markdown() {
995 let config = Config::default();
996 let factory = mock_factory();
997 let tmp = tempfile::NamedTempFile::new().unwrap();
998 let args = ExportArgs {
999 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1000 address_book: false,
1001 output: tmp.path().to_path_buf(),
1002 format: Some(OutputFormat::Markdown),
1003 chain: "ethereum".to_string(),
1004 from: None,
1005 to: None,
1006 limit: 100,
1007 };
1008 let result = run(args, &config, &factory).await;
1009 assert!(result.is_ok());
1010 let content = std::fs::read_to_string(tmp.path()).unwrap();
1011 assert!(content.contains("# Transaction Export"));
1012 assert!(
1013 content.contains("| Hash | Block | Timestamp | From | To | Value | Gas | Status |")
1014 );
1015 assert!(content.contains("0xexport1"));
1016 }
1017
1018 #[tokio::test]
1019 async fn test_run_export_address_table_error() {
1020 let config = Config::default();
1021 let factory = mock_factory();
1022 let tmp = tempfile::NamedTempFile::new().unwrap();
1023 let args = ExportArgs {
1024 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1025 address_book: false,
1026 output: tmp.path().to_path_buf(),
1027 format: Some(OutputFormat::Table),
1028 chain: "ethereum".to_string(),
1029 from: None,
1030 to: None,
1031 limit: 100,
1032 };
1033 let result = run(args, &config, &factory).await;
1034 assert!(result.is_err()); }
1036
1037 #[tokio::test]
1038 async fn test_run_export_address_with_date_filter_before() {
1039 let config = Config::default();
1040 let mut factory = MockClientFactory::new();
1041 factory.mock_client = MockChainClient::new("ethereum", "ETH");
1042 factory.mock_client.transactions = vec![crate::chains::Transaction {
1044 hash: "0xbefore".to_string(),
1045 block_number: Some(100),
1046 timestamp: Some(1690000000), from: "0xfrom".to_string(),
1048 to: Some("0xto".to_string()),
1049 value: "1.0".to_string(),
1050 gas_limit: 21000,
1051 gas_used: Some(21000),
1052 gas_price: "20000000000".to_string(),
1053 nonce: 0,
1054 input: "0x".to_string(),
1055 status: Some(true),
1056 }];
1057 let tmp = tempfile::NamedTempFile::new().unwrap();
1058 let args = ExportArgs {
1059 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1060 address_book: false,
1061 output: tmp.path().to_path_buf(),
1062 format: Some(OutputFormat::Json),
1063 chain: "ethereum".to_string(),
1064 from: Some("2024-01-01".to_string()), to: None,
1066 limit: 100,
1067 };
1068 let result = run(args, &config, &factory).await;
1069 assert!(result.is_ok());
1070 let content = std::fs::read_to_string(tmp.path()).unwrap();
1071 assert!(!content.contains("0xbefore"));
1073 }
1074
1075 #[tokio::test]
1076 async fn test_run_export_address_with_date_filter_after() {
1077 let config = Config::default();
1078 let mut factory = MockClientFactory::new();
1079 factory.mock_client = MockChainClient::new("ethereum", "ETH");
1080 factory.mock_client.transactions = vec![crate::chains::Transaction {
1082 hash: "0xafter".to_string(),
1083 block_number: Some(100),
1084 timestamp: Some(1800000000), from: "0xfrom".to_string(),
1086 to: Some("0xto".to_string()),
1087 value: "1.0".to_string(),
1088 gas_limit: 21000,
1089 gas_used: Some(21000),
1090 gas_price: "20000000000".to_string(),
1091 nonce: 0,
1092 input: "0x".to_string(),
1093 status: Some(true),
1094 }];
1095 let tmp = tempfile::NamedTempFile::new().unwrap();
1096 let args = ExportArgs {
1097 address: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()),
1098 address_book: false,
1099 output: tmp.path().to_path_buf(),
1100 format: Some(OutputFormat::Json),
1101 chain: "ethereum".to_string(),
1102 from: None,
1103 to: Some("2025-12-31".to_string()), limit: 100,
1105 };
1106 let result = run(args, &config, &factory).await;
1107 assert!(result.is_ok());
1108 let content = std::fs::read_to_string(tmp.path()).unwrap();
1109 assert!(!content.contains("0xafter"));
1111 }
1112
1113 #[test]
1118 fn test_export_args_debug() {
1119 let args = ExportArgs {
1120 address: Some("0xtest".to_string()),
1121 address_book: false,
1122 output: PathBuf::from("test.json"),
1123 format: Some(OutputFormat::Json),
1124 chain: "ethereum".to_string(),
1125 from: None,
1126 to: None,
1127 limit: 100,
1128 };
1129 let debug_str = format!("{:?}", args);
1130 assert!(debug_str.contains("ExportArgs"));
1131 assert!(debug_str.contains("0xtest"));
1132 }
1133
1134 #[test]
1135 fn test_export_report_debug() {
1136 let report = ExportReport {
1137 export_type: "address".to_string(),
1138 record_count: 42,
1139 output_path: "/tmp/test.json".to_string(),
1140 format: "json".to_string(),
1141 exported_at: 1700000000,
1142 };
1143 let debug_str = format!("{:?}", report);
1144 assert!(debug_str.contains("ExportReport"));
1145 assert!(debug_str.contains("address"));
1146 assert!(debug_str.contains("42"));
1147 }
1148
1149 #[test]
1150 fn test_transaction_export_debug() {
1151 let tx = TransactionExport {
1152 hash: "0xabc123".to_string(),
1153 block_number: 12345,
1154 timestamp: 1700000000,
1155 from: "0xfrom".to_string(),
1156 to: Some("0xto".to_string()),
1157 value: "1.5".to_string(),
1158 gas_used: 21000,
1159 status: true,
1160 };
1161 let debug_str = format!("{:?}", tx);
1162 assert!(debug_str.contains("TransactionExport"));
1163 assert!(debug_str.contains("0xabc123"));
1164 }
1165
1166 #[test]
1167 fn test_transaction_export_debug_no_to() {
1168 let tx = TransactionExport {
1169 hash: "0xcreate".to_string(),
1170 block_number: 100,
1171 timestamp: 1700000000,
1172 from: "0xdeployer".to_string(),
1173 to: None,
1174 value: "0".to_string(),
1175 gas_used: 500000,
1176 status: true,
1177 };
1178 let debug_str = format!("{:?}", tx);
1179 assert!(debug_str.contains("TransactionExport"));
1180 assert!(debug_str.contains("0xcreate"));
1181 }
1182
1183 #[test]
1184 fn test_export_data_debug() {
1185 let data = ExportData {
1186 address: "0xtest".to_string(),
1187 chain: "ethereum".to_string(),
1188 transactions: vec![],
1189 exported_at: 1700000000,
1190 };
1191 let debug_str = format!("{:?}", data);
1192 assert!(debug_str.contains("ExportData"));
1193 assert!(debug_str.contains("0xtest"));
1194 assert!(debug_str.contains("ethereum"));
1195 }
1196
1197 #[test]
1198 fn test_export_data_debug_with_transactions() {
1199 let data = ExportData {
1200 address: "0xtest".to_string(),
1201 chain: "ethereum".to_string(),
1202 transactions: vec![TransactionExport {
1203 hash: "0xabc".to_string(),
1204 block_number: 1,
1205 timestamp: 0,
1206 from: "0x1".to_string(),
1207 to: Some("0x2".to_string()),
1208 value: "0".to_string(),
1209 gas_used: 21000,
1210 status: true,
1211 }],
1212 exported_at: 1700000000,
1213 };
1214 let debug_str = format!("{:?}", data);
1215 assert!(debug_str.contains("ExportData"));
1216 assert!(debug_str.contains("0xabc"));
1217 }
1218
1219 #[test]
1224 fn test_detect_format_markdown() {
1225 let path = PathBuf::from("output.md");
1226 assert_eq!(detect_format(&path), OutputFormat::Json);
1228 }
1229
1230 #[test]
1231 fn test_detect_format_txt() {
1232 let path = PathBuf::from("output.txt");
1233 assert_eq!(detect_format(&path), OutputFormat::Json);
1234 }
1235
1236 #[test]
1237 fn test_parse_date_to_ts_future_date() {
1238 let ts = parse_date_to_ts("2100-01-01");
1239 assert!(ts.is_some());
1240 let ts = ts.unwrap();
1241 assert!(ts > 4000000000);
1243 }
1244
1245 #[test]
1246 fn test_parse_date_to_ts_leap_year() {
1247 let ts = parse_date_to_ts("2024-02-29");
1248 assert!(ts.is_some());
1249 }
1250
1251 #[test]
1252 fn test_parse_date_to_ts_non_leap_year_feb_29() {
1253 let ts = parse_date_to_ts("2023-02-29");
1256 let _ = ts;
1259 }
1260
1261 #[test]
1262 fn test_days_since_epoch_leap_year() {
1263 let days = days_since_epoch(2024, 2, 29);
1264 assert!(days.is_some());
1265 }
1266
1267 #[test]
1268 fn test_days_since_epoch_year_before_epoch() {
1269 let days = days_since_epoch(1969, 12, 31);
1270 assert!(days.is_some());
1271 assert!(days.unwrap() < 0);
1272 }
1273
1274 #[test]
1275 fn test_days_since_epoch_future_year() {
1276 let days = days_since_epoch(2100, 1, 1);
1277 assert!(days.is_some());
1278 assert!(days.unwrap() > 0);
1279 }
1280}