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