Skip to main content

scope_cli/cli/
export.rs

1//! # Export Command
2//!
3//! This module implements the `scope export` command for exporting
4//! analysis data to various formats (JSON, CSV).
5//!
6//! ## Usage
7//!
8//! ```bash
9//! # Export address history to JSON
10//! scope export --address 0x742d... --output history.json
11//!
12//! # Export to CSV
13//! scope export --address 0x742d... --output history.csv --format csv
14//!
15//! # Export address book data
16//! scope export --address-book --output address_book.json
17//! ```
18
19use 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/// Arguments for the export command.
28#[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    /// Address to export data for. Use @label for address book shortcut.
37    #[arg(short, long, value_name = "ADDRESS", group = "source")]
38    pub address: Option<String>,
39
40    /// Export address book data.
41    #[arg(
42        long = "address-book",
43        short = 'p',
44        alias = "portfolio",
45        group = "source"
46    )]
47    pub address_book: bool,
48
49    /// Output file path.
50    #[arg(short, long, value_name = "PATH")]
51    pub output: PathBuf,
52
53    /// Output format (auto-detected from extension if not specified).
54    #[arg(short, long, value_name = "FORMAT")]
55    pub format: Option<OutputFormat>,
56
57    /// Target blockchain network (for address export).
58    #[arg(short, long, default_value = scope::chains::DEFAULT_CHAIN)]
59    pub chain: String,
60
61    /// Start date for transaction history (YYYY-MM-DD).
62    #[arg(long, value_name = "DATE")]
63    pub from: Option<String>,
64
65    /// End date for transaction history (YYYY-MM-DD).
66    #[arg(long, value_name = "DATE")]
67    pub to: Option<String>,
68
69    /// Maximum number of transactions to export.
70    #[arg(long, default_value = "1000")]
71    pub limit: u32,
72}
73
74/// Data export report.
75#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
76pub struct ExportReport {
77    /// Export type (address, address book).
78    pub export_type: String,
79
80    /// Number of records exported.
81    pub record_count: usize,
82
83    /// Output file path.
84    pub output_path: String,
85
86    /// Export format.
87    pub format: String,
88
89    /// Export timestamp.
90    pub exported_at: u64,
91}
92
93/// Executes the export command.
94///
95/// # Arguments
96///
97/// * `args` - The parsed command arguments
98/// * `config` - Application configuration
99///
100/// # Returns
101///
102/// Returns `Ok(())` on success, or an error if the export fails.
103///
104/// # Errors
105///
106/// Returns [`ScopeError::Export`] if the export operation fails.
107/// Returns [`ScopeError::IoError`] if file operations fail.
108pub async fn run(
109    mut args: ExportArgs,
110    config: &Config,
111    clients: &dyn ChainClientFactory,
112    out: &Output,
113) -> Result<()> {
114    // Resolve address book label → address + chain
115    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    // Determine format from argument or file extension
126    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
148/// Detects output format from file extension.
149fn 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, // Default to JSON
154    }
155}
156
157/// Exports address book data.
158async 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
228/// Exports address transaction history.
229async fn export_address(
230    address: &str,
231    args: &ExportArgs,
232    format: OutputFormat,
233    clients: &dyn ChainClientFactory,
234    out: &Output,
235) -> Result<()> {
236    // Auto-detect chain if default
237    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    // Fetch real transaction history
254    let client = clients.create_chain_client(&chain)?;
255    let chain_txs = client.get_transactions(address, args.limit).await?;
256
257    // Apply date filtering if --from / --to are provided
258    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
368/// Parses a YYYY-MM-DD date string to a Unix timestamp.
369fn 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    // Simple calculation: days since epoch * 86400
379    // Use chrono-like calculation without the crate
380    // For simplicity, use a basic approach
381    let days_from_epoch = days_since_epoch(year, month, day)?;
382    Some((days_from_epoch as u64) * 86400)
383}
384
385/// Calculates days since Unix epoch (1970-01-01) for a given date.
386fn 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    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
392    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/// Exported transaction data.
403#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
404pub struct TransactionExport {
405    /// Transaction hash.
406    pub hash: String,
407
408    /// Block number.
409    pub block_number: u64,
410
411    /// Timestamp.
412    pub timestamp: u64,
413
414    /// Sender address.
415    pub from: String,
416
417    /// Recipient address.
418    pub to: Option<String>,
419
420    /// Value transferred.
421    pub value: String,
422
423    /// Gas used.
424    pub gas_used: u64,
425
426    /// Transaction status.
427    pub status: bool,
428}
429
430/// Export data container.
431#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
432pub struct ExportData {
433    /// The exported address.
434    pub address: String,
435
436    /// The chain.
437    pub chain: String,
438
439    /// Transactions.
440    pub transactions: Vec<TransactionExport>,
441
442    /// Export timestamp.
443    pub exported_at: u64,
444}
445
446// ============================================================================
447// Unit Tests
448// ============================================================================
449
450#[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        // Test primary --address-book flag
521        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        // Test backward-compat --portfolio alias
528        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        // Create a test address book
627        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    // ========================================================================
760    // Date parsing and pure function tests
761    // ========================================================================
762
763    #[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        // Jan 1, 2024 00:00:00 UTC should be around 1704067200
769        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()); // Month > 12
789        assert!(parse_date_to_ts("2024-00-01").is_none()); // Month 0
790        assert!(parse_date_to_ts("2024-01-00").is_none()); // Day 0
791        assert!(parse_date_to_ts("2024-01-32").is_none()); // Day > 31
792    }
793
794    #[test]
795    fn test_days_since_epoch_basic() {
796        // Jan 1, 1970 should be day 0
797        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        // 2000-01-01 is day 10957
804        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        // Create empty address book
827        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()); // Table format not supported for export
851    }
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    // ========================================================================
878    // End-to-end tests using MockClientFactory
879    // ========================================================================
880
881    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        // Verify file was written
921        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(), // Non-ethereum chain
972            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()); // Table format not supported for export
1044    }
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        // Transaction with timestamp 1700000000 (2023-11-14)
1052        factory.mock_client.transactions = vec![scope::chains::Transaction {
1053            hash: "0xbefore".to_string(),
1054            block_number: Some(100),
1055            timestamp: Some(1690000000), // Before filter
1056            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()), // Filter: only after 2024-01-01
1074            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        // Transaction should be filtered out (before from date)
1081        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        // Transaction with timestamp 1800000000 (2027-01-14)
1090        factory.mock_client.transactions = vec![scope::chains::Transaction {
1091            hash: "0xafter".to_string(),
1092            block_number: Some(100),
1093            timestamp: Some(1800000000), // After filter
1094            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()), // Filter: only before 2025-12-31
1113            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        // Transaction should be filtered out (after to date)
1119        assert!(!content.contains("0xafter"));
1120    }
1121
1122    // ========================================================================
1123    // Debug trait tests
1124    // ========================================================================
1125
1126    #[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    // ========================================================================
1229    // Additional pure function tests
1230    // ========================================================================
1231
1232    #[test]
1233    fn test_detect_format_markdown() {
1234        let path = PathBuf::from("output.md");
1235        // Markdown extension should default to JSON (not explicitly handled)
1236        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        // Should be a large timestamp
1251        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        // 2023 is not a leap year, but our simple function doesn't validate this
1263        // It will still return a value, just potentially incorrect
1264        let ts = parse_date_to_ts("2023-02-29");
1265        // The function doesn't validate leap years, so it may return Some
1266        // or None depending on implementation
1267        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}