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