Skip to main content

scope/cli/
setup.rs

1//! # Setup Command
2//!
3//! This module implements the `setup` command for interactive configuration
4//! of the Scope application. It walks users through setting up API keys and
5//! preferences.
6//!
7//! ## Usage
8//!
9//! ```bash
10//! # Run the full setup wizard
11//! scope setup
12//!
13//! # Show current configuration status
14//! scope setup --status
15//!
16//! # Set a specific API key
17//! scope setup --key etherscan
18//! ```
19
20use crate::config::{Config, OutputFormat};
21use crate::error::{ConfigError, Result, ScopeError};
22use clap::Args;
23use std::io::{self, BufRead, Write};
24use std::path::{Path, PathBuf};
25
26/// Arguments for the setup command.
27#[derive(Debug, Args)]
28#[command(after_help = "\x1b[1mExamples:\x1b[0m
29  scope setup
30  scope setup --status
31  scope setup --key etherscan
32  scope setup --reset")]
33pub struct SetupArgs {
34    /// Show current configuration status without making changes.
35    #[arg(long, short)]
36    pub status: bool,
37
38    /// Configure a specific API key only.
39    #[arg(long, short, value_name = "PROVIDER")]
40    pub key: Option<String>,
41
42    /// Reset configuration to defaults.
43    #[arg(long)]
44    pub reset: bool,
45}
46
47/// Configuration item with metadata for display.
48#[allow(dead_code)]
49struct ConfigItem {
50    name: &'static str,
51    description: &'static str,
52    env_var: &'static str,
53    is_set: bool,
54    value_hint: Option<String>,
55}
56
57/// Runs the setup command.
58pub async fn run(args: SetupArgs, config: &Config) -> Result<()> {
59    if args.status {
60        show_status(config);
61        return Ok(());
62    }
63
64    if args.reset {
65        return reset_config();
66    }
67
68    if let Some(ref key_name) = args.key {
69        return configure_single_key(key_name, config).await;
70    }
71
72    // Run full setup wizard
73    run_setup_wizard(config).await
74}
75
76/// Shows the current configuration status.
77fn show_status(config: &Config) {
78    use crate::display::terminal as t;
79
80    println!("{}", t::section_header("Scope Configuration Status"));
81
82    // Config file location
83    let config_path = Config::config_path()
84        .map(|p| p.display().to_string())
85        .unwrap_or_else(|| "Not found".to_string());
86    println!("{}", t::kv_row("Config file", &config_path));
87    println!("{}", t::blank_row());
88
89    // API Keys
90    println!("{}", t::subsection_header("API Keys"));
91
92    let api_keys = get_api_key_items(config);
93    let mut missing_keys = Vec::new();
94
95    for item in &api_keys {
96        let info = get_api_key_info(item.name);
97        if item.is_set {
98            let hint = item.value_hint.as_deref().unwrap_or("");
99            let msg = if hint.is_empty() {
100                item.name.to_string()
101            } else {
102                format!("{} {}", item.name, hint)
103            };
104            println!("{}", t::check_pass(&msg));
105        } else {
106            missing_keys.push(item.name);
107            println!("{}", t::check_fail(item.name));
108        }
109        println!("{}", t::kv_row("Chain", info.chain));
110    }
111
112    // Show where to get missing keys
113    if !missing_keys.is_empty() {
114        println!("{}", t::blank_row());
115        println!("{}", t::subsection_header("Missing API Keys"));
116        for key_name in missing_keys {
117            let info = get_api_key_info(key_name);
118            println!("{}", t::link_row(key_name, info.url));
119        }
120    }
121
122    println!("{}", t::blank_row());
123    println!("{}", t::subsection_header("Defaults"));
124    println!(
125        "{}",
126        t::kv_row(
127            "Chain",
128            config.chains.ethereum_rpc.as_deref().unwrap_or("ethereum")
129        )
130    );
131    println!(
132        "{}",
133        t::kv_row("Output format", &format!("{:?}", config.output.format))
134    );
135    println!(
136        "{}",
137        t::kv_row(
138            "Color output",
139            if config.output.color {
140                "enabled"
141            } else {
142                "disabled"
143            }
144        )
145    );
146
147    // Ghola sidecar status
148    println!("{}", t::blank_row());
149    println!("{}", t::subsection_header("Ghola Sidecar"));
150
151    let ghola_in_path = which_ghola();
152    if ghola_in_path {
153        println!("{}", t::check_pass("ghola binary found in PATH"));
154    } else {
155        println!("{}", t::check_fail("ghola binary not found in PATH"));
156        println!(
157            "{}",
158            t::info_row("Install: go install github.com/robot-accomplice/ghola@latest")
159        );
160    }
161
162    if config.ghola.enabled {
163        println!("{}", t::check_pass("Ghola transport enabled in config"));
164        if config.ghola.stealth {
165            println!(
166                "{}",
167                t::check_pass("Stealth mode active (temporal drift + ghost signing)")
168            );
169        } else {
170            println!(
171                "{}",
172                t::kv_row(
173                    "Stealth mode",
174                    "disabled (set ghola.stealth: true to enable)"
175                )
176            );
177        }
178        println!(
179            "{}",
180            t::kv_row("Buffer size", &format!("{} bytes", config.ghola.buffer_size))
181        );
182    } else {
183        println!(
184            "{}",
185            t::kv_row(
186                "Transport",
187                "native (set ghola.enabled: true in config to use sidecar)",
188            )
189        );
190    }
191
192    println!("{}", t::blank_row());
193    println!(
194        "{}",
195        t::info_row("Run 'scope setup' to configure missing settings.")
196    );
197    println!(
198        "{}",
199        t::info_row("Run 'scope setup --key <provider>' to configure a specific key.")
200    );
201    println!("{}", t::section_footer());
202}
203
204/// Checks whether the `ghola` binary is present on `$PATH`.
205fn which_ghola() -> bool {
206    std::process::Command::new("which")
207        .arg("ghola")
208        .stdout(std::process::Stdio::null())
209        .stderr(std::process::Stdio::null())
210        .status()
211        .map(|s| s.success())
212        .unwrap_or(false)
213}
214
215/// Gets API key configuration items.
216fn get_api_key_items(config: &Config) -> Vec<ConfigItem> {
217    vec![
218        ConfigItem {
219            name: "etherscan",
220            description: "Ethereum mainnet block explorer",
221            env_var: "SCOPE_ETHERSCAN_API_KEY",
222            is_set: config.chains.api_keys.contains_key("etherscan"),
223            value_hint: config.chains.api_keys.get("etherscan").map(|k| mask_key(k)),
224        },
225        ConfigItem {
226            name: "bscscan",
227            description: "BNB Smart Chain block explorer",
228            env_var: "SCOPE_BSCSCAN_API_KEY",
229            is_set: config.chains.api_keys.contains_key("bscscan"),
230            value_hint: config.chains.api_keys.get("bscscan").map(|k| mask_key(k)),
231        },
232        ConfigItem {
233            name: "polygonscan",
234            description: "Polygon block explorer",
235            env_var: "SCOPE_POLYGONSCAN_API_KEY",
236            is_set: config.chains.api_keys.contains_key("polygonscan"),
237            value_hint: config
238                .chains
239                .api_keys
240                .get("polygonscan")
241                .map(|k| mask_key(k)),
242        },
243        ConfigItem {
244            name: "arbiscan",
245            description: "Arbitrum block explorer",
246            env_var: "SCOPE_ARBISCAN_API_KEY",
247            is_set: config.chains.api_keys.contains_key("arbiscan"),
248            value_hint: config.chains.api_keys.get("arbiscan").map(|k| mask_key(k)),
249        },
250        ConfigItem {
251            name: "basescan",
252            description: "Base block explorer",
253            env_var: "SCOPE_BASESCAN_API_KEY",
254            is_set: config.chains.api_keys.contains_key("basescan"),
255            value_hint: config.chains.api_keys.get("basescan").map(|k| mask_key(k)),
256        },
257        ConfigItem {
258            name: "optimism",
259            description: "Optimism block explorer",
260            env_var: "SCOPE_OPTIMISM_API_KEY",
261            is_set: config.chains.api_keys.contains_key("optimism"),
262            value_hint: config.chains.api_keys.get("optimism").map(|k| mask_key(k)),
263        },
264    ]
265}
266
267/// Masks an API key for display (shows first 4 and last 4 chars).
268fn mask_key(key: &str) -> String {
269    if key.len() <= 8 {
270        return "*".repeat(key.len());
271    }
272    format!("({}...{})", &key[..4], &key[key.len() - 4..])
273}
274
275/// Resets configuration to defaults.
276fn reset_config() -> Result<()> {
277    let config_path = Config::config_path().ok_or_else(|| {
278        ScopeError::Config(ConfigError::NotFound {
279            path: PathBuf::from("~/.config/scope/config.yaml"),
280        })
281    })?;
282    let stdin = io::stdin();
283    let stdout = io::stdout();
284    reset_config_impl(&mut stdin.lock(), &mut stdout.lock(), &config_path)
285}
286
287/// Testable implementation of reset_config with injected I/O and path.
288fn reset_config_impl(
289    reader: &mut impl BufRead,
290    writer: &mut impl Write,
291    config_path: &Path,
292) -> Result<()> {
293    if config_path.exists() {
294        write!(
295            writer,
296            "This will delete your current configuration. Continue? [y/N]: "
297        )
298        .map_err(|e| ScopeError::Io(e.to_string()))?;
299        writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
300
301        let mut input = String::new();
302        reader
303            .read_line(&mut input)
304            .map_err(|e| ScopeError::Io(e.to_string()))?;
305
306        if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
307            writeln!(writer, "Cancelled.").map_err(|e| ScopeError::Io(e.to_string()))?;
308            return Ok(());
309        }
310
311        std::fs::remove_file(config_path).map_err(|e| ScopeError::Io(e.to_string()))?;
312        writeln!(writer, "Configuration reset to defaults.")
313            .map_err(|e| ScopeError::Io(e.to_string()))?;
314    } else {
315        writeln!(
316            writer,
317            "No configuration file found. Already using defaults."
318        )
319        .map_err(|e| ScopeError::Io(e.to_string()))?;
320    }
321
322    Ok(())
323}
324
325/// Configures a single API key.
326async fn configure_single_key(key_name: &str, config: &Config) -> Result<()> {
327    let config_path = Config::config_path().ok_or_else(|| {
328        ScopeError::Config(ConfigError::NotFound {
329            path: PathBuf::from("~/.config/scope/config.yaml"),
330        })
331    })?;
332    let stdin = io::stdin();
333    let stdout = io::stdout();
334    configure_single_key_impl(
335        &mut stdin.lock(),
336        &mut stdout.lock(),
337        key_name,
338        config,
339        &config_path,
340    )
341}
342
343/// Testable implementation of configure_single_key with injected I/O.
344fn configure_single_key_impl(
345    reader: &mut impl BufRead,
346    writer: &mut impl Write,
347    key_name: &str,
348    config: &Config,
349    config_path: &Path,
350) -> Result<()> {
351    let valid_keys = [
352        "etherscan",
353        "bscscan",
354        "polygonscan",
355        "arbiscan",
356        "basescan",
357        "optimism",
358    ];
359
360    if !valid_keys.contains(&key_name) {
361        writeln!(writer, "Unknown API key: {}", key_name)
362            .map_err(|e| ScopeError::Io(e.to_string()))?;
363        writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
364        writeln!(writer, "Valid options:").map_err(|e| ScopeError::Io(e.to_string()))?;
365        for key in valid_keys {
366            let info = get_api_key_info(key);
367            writeln!(writer, "  {:<15} - {}", key, info.chain)
368                .map_err(|e| ScopeError::Io(e.to_string()))?;
369        }
370        return Ok(());
371    }
372
373    let info = get_api_key_info(key_name);
374    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
375    writeln!(
376        writer,
377        "╔══════════════════════════════════════════════════════════════╗"
378    )
379    .map_err(|e| ScopeError::Io(e.to_string()))?;
380    writeln!(writer, "║  Configure {} API Key", key_name.to_uppercase())
381        .map_err(|e| ScopeError::Io(e.to_string()))?;
382    writeln!(
383        writer,
384        "╚══════════════════════════════════════════════════════════════╝"
385    )
386    .map_err(|e| ScopeError::Io(e.to_string()))?;
387    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
388    writeln!(writer, "Chain: {}", info.chain).map_err(|e| ScopeError::Io(e.to_string()))?;
389    writeln!(writer, "Enables: {}", info.features).map_err(|e| ScopeError::Io(e.to_string()))?;
390    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
391    writeln!(writer, "How to get your free API key:").map_err(|e| ScopeError::Io(e.to_string()))?;
392    writeln!(writer, "  {}", info.signup_steps).map_err(|e| ScopeError::Io(e.to_string()))?;
393    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
394    writeln!(writer, "URL: {}", info.url).map_err(|e| ScopeError::Io(e.to_string()))?;
395    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
396
397    let key = prompt_api_key_impl(reader, writer, key_name)?;
398
399    if key.is_empty() {
400        writeln!(writer, "Skipped.").map_err(|e| ScopeError::Io(e.to_string()))?;
401        return Ok(());
402    }
403
404    // Update config with new key
405    let mut new_config = config.clone();
406    new_config.chains.api_keys.insert(key_name.to_string(), key);
407
408    save_config_to_path(&new_config, config_path)?;
409    writeln!(writer, "✓ {} API key saved.", key_name).map_err(|e| ScopeError::Io(e.to_string()))?;
410
411    Ok(())
412}
413
414/// API key information for each supported provider.
415struct ApiKeyInfo {
416    url: &'static str,
417    chain: &'static str,
418    features: &'static str,
419    signup_steps: &'static str,
420}
421
422/// Gets detailed information for obtaining an API key.
423fn get_api_key_info(key_name: &str) -> ApiKeyInfo {
424    match key_name {
425        "etherscan" => ApiKeyInfo {
426            url: "https://etherscan.io/apis",
427            chain: "Ethereum Mainnet",
428            features: "token balances, transactions, holders, contract verification",
429            signup_steps: "1. Visit etherscan.io/register\n     2. Create a free account\n     3. Go to API-Keys in your account\n     4. Click 'Add' to generate a new key",
430        },
431        "bscscan" => ApiKeyInfo {
432            url: "https://bscscan.com/apis",
433            chain: "BNB Smart Chain (BSC)",
434            features: "BSC token data, BEP-20 holders, transactions",
435            signup_steps: "1. Visit bscscan.com/register\n     2. Create a free account\n     3. Go to API-Keys in your account\n     4. Click 'Add' to generate a new key",
436        },
437        "polygonscan" => ApiKeyInfo {
438            url: "https://polygonscan.com/apis",
439            chain: "Polygon (MATIC)",
440            features: "Polygon token data, transactions, holders",
441            signup_steps: "1. Visit polygonscan.com/register\n     2. Create a free account\n     3. Go to API-Keys in your account\n     4. Click 'Add' to generate a new key",
442        },
443        "arbiscan" => ApiKeyInfo {
444            url: "https://arbiscan.io/apis",
445            chain: "Arbitrum One",
446            features: "Arbitrum token data, L2 transactions, holders",
447            signup_steps: "1. Visit arbiscan.io/register\n     2. Create a free account\n     3. Go to API-Keys in your account\n     4. Click 'Add' to generate a new key",
448        },
449        "basescan" => ApiKeyInfo {
450            url: "https://basescan.org/apis",
451            chain: "Base (Coinbase L2)",
452            features: "Base token data, transactions, holders",
453            signup_steps: "1. Visit basescan.org/register\n     2. Create a free account\n     3. Go to API-Keys in your account\n     4. Click 'Add' to generate a new key",
454        },
455        "optimism" => ApiKeyInfo {
456            url: "https://optimistic.etherscan.io/apis",
457            chain: "Optimism (OP Mainnet)",
458            features: "Optimism token data, L2 transactions, holders",
459            signup_steps: "1. Visit optimistic.etherscan.io/register\n     2. Create a free account\n     3. Go to API-Keys in your account\n     4. Click 'Add' to generate a new key",
460        },
461        _ => ApiKeyInfo {
462            url: "https://etherscan.io/apis",
463            chain: "Ethereum",
464            features: "blockchain data",
465            signup_steps: "Visit the provider's website to register",
466        },
467    }
468}
469
470/// Gets the URL for obtaining an API key (for backwards compatibility).
471#[cfg(test)]
472fn get_api_key_url(key_name: &str) -> &'static str {
473    get_api_key_info(key_name).url
474}
475
476/// Runs the full setup wizard.
477async fn run_setup_wizard(config: &Config) -> Result<()> {
478    let config_path = Config::config_path().ok_or_else(|| {
479        ScopeError::Config(ConfigError::NotFound {
480            path: PathBuf::from("~/.config/scope/config.yaml"),
481        })
482    })?;
483    let stdin = io::stdin();
484    let stdout = io::stdout();
485    run_setup_wizard_impl(&mut stdin.lock(), &mut stdout.lock(), config, &config_path)
486}
487
488/// Testable implementation of the setup wizard with injected I/O.
489fn run_setup_wizard_impl(
490    reader: &mut impl BufRead,
491    writer: &mut impl Write,
492    config: &Config,
493    config_path: &Path,
494) -> Result<()> {
495    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
496    writeln!(
497        writer,
498        "╔══════════════════════════════════════════════════════════════╗"
499    )
500    .map_err(|e| ScopeError::Io(e.to_string()))?;
501    writeln!(
502        writer,
503        "║                    Scope Setup Wizard                          ║"
504    )
505    .map_err(|e| ScopeError::Io(e.to_string()))?;
506    writeln!(
507        writer,
508        "╚══════════════════════════════════════════════════════════════╝"
509    )
510    .map_err(|e| ScopeError::Io(e.to_string()))?;
511    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
512    writeln!(
513        writer,
514        "This wizard will help you configure Scope (Blockchain Crawler CLI)."
515    )
516    .map_err(|e| ScopeError::Io(e.to_string()))?;
517    writeln!(writer, "Press Enter to skip any optional setting.")
518        .map_err(|e| ScopeError::Io(e.to_string()))?;
519    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
520
521    let mut new_config = config.clone();
522    let mut changes_made = false;
523
524    // Step 1: API Keys
525    writeln!(writer, "Step 1: API Keys").map_err(|e| ScopeError::Io(e.to_string()))?;
526    writeln!(writer, "{}", "=".repeat(60)).map_err(|e| ScopeError::Io(e.to_string()))?;
527    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
528    writeln!(
529        writer,
530        "API keys enable access to block explorer data including:"
531    )
532    .map_err(|e| ScopeError::Io(e.to_string()))?;
533    writeln!(writer, "  • Token balances and holder information")
534        .map_err(|e| ScopeError::Io(e.to_string()))?;
535    writeln!(writer, "  • Transaction history and details")
536        .map_err(|e| ScopeError::Io(e.to_string()))?;
537    writeln!(writer, "  • Contract verification status")
538        .map_err(|e| ScopeError::Io(e.to_string()))?;
539    writeln!(writer, "  • Token analytics and metrics")
540        .map_err(|e| ScopeError::Io(e.to_string()))?;
541    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
542    writeln!(
543        writer,
544        "All API keys are FREE and take just a minute to obtain."
545    )
546    .map_err(|e| ScopeError::Io(e.to_string()))?;
547    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
548
549    // Etherscan (primary)
550    if !config.chains.api_keys.contains_key("etherscan") {
551        let info = get_api_key_info("etherscan");
552        writeln!(
553            writer,
554            "┌────────────────────────────────────────────────────────────┐"
555        )
556        .map_err(|e| ScopeError::Io(e.to_string()))?;
557        writeln!(
558            writer,
559            "│  ETHERSCAN API KEY (Recommended)                           │"
560        )
561        .map_err(|e| ScopeError::Io(e.to_string()))?;
562        writeln!(
563            writer,
564            "└────────────────────────────────────────────────────────────┘"
565        )
566        .map_err(|e| ScopeError::Io(e.to_string()))?;
567        writeln!(writer, "  Chain: {}", info.chain).map_err(|e| ScopeError::Io(e.to_string()))?;
568        writeln!(writer, "  Enables: {}", info.features)
569            .map_err(|e| ScopeError::Io(e.to_string()))?;
570        writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
571        writeln!(writer, "  How to get your free API key:")
572            .map_err(|e| ScopeError::Io(e.to_string()))?;
573        writeln!(writer, "  {}", info.signup_steps).map_err(|e| ScopeError::Io(e.to_string()))?;
574        writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
575        writeln!(writer, "  URL: {}", info.url).map_err(|e| ScopeError::Io(e.to_string()))?;
576        writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
577        if let Some(key) = prompt_optional_key_impl(reader, writer, "etherscan")? {
578            new_config
579                .chains
580                .api_keys
581                .insert("etherscan".to_string(), key);
582            changes_made = true;
583        }
584        writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
585    } else {
586        writeln!(writer, "✓ Etherscan API key already configured")
587            .map_err(|e| ScopeError::Io(e.to_string()))?;
588        writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
589    }
590
591    // Ask about other chains
592    write!(
593        writer,
594        "Configure API keys for other chains (BSC, Polygon, Arbitrum, etc.)? [y/N]: "
595    )
596    .map_err(|e| ScopeError::Io(e.to_string()))?;
597    writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
598
599    let mut input = String::new();
600    reader
601        .read_line(&mut input)
602        .map_err(|e| ScopeError::Io(e.to_string()))?;
603
604    if matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
605        writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
606
607        let other_chains = ["bscscan", "polygonscan", "arbiscan", "basescan", "optimism"];
608
609        for key_name in other_chains {
610            if !config.chains.api_keys.contains_key(key_name) {
611                let info = get_api_key_info(key_name);
612                writeln!(
613                    writer,
614                    "┌────────────────────────────────────────────────────────────┐"
615                )
616                .map_err(|e| ScopeError::Io(e.to_string()))?;
617                writeln!(writer, "│  {} API KEY", key_name.to_uppercase())
618                    .map_err(|e| ScopeError::Io(e.to_string()))?;
619                writeln!(
620                    writer,
621                    "└────────────────────────────────────────────────────────────┘"
622                )
623                .map_err(|e| ScopeError::Io(e.to_string()))?;
624                writeln!(writer, "  Chain: {}", info.chain)
625                    .map_err(|e| ScopeError::Io(e.to_string()))?;
626                writeln!(writer, "  Enables: {}", info.features)
627                    .map_err(|e| ScopeError::Io(e.to_string()))?;
628                writeln!(writer, "  URL: {}", info.url)
629                    .map_err(|e| ScopeError::Io(e.to_string()))?;
630                writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
631                if let Some(key) = prompt_optional_key_impl(reader, writer, key_name)? {
632                    new_config.chains.api_keys.insert(key_name.to_string(), key);
633                    changes_made = true;
634                }
635                writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
636            }
637        }
638    }
639
640    // Step 2: Preferences
641    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
642    writeln!(writer, "Step 2: Preferences").map_err(|e| ScopeError::Io(e.to_string()))?;
643    writeln!(writer, "{}", "=".repeat(60)).map_err(|e| ScopeError::Io(e.to_string()))?;
644    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
645
646    // Default output format
647    writeln!(writer, "Default output format:").map_err(|e| ScopeError::Io(e.to_string()))?;
648    writeln!(writer, "  1. table (default)").map_err(|e| ScopeError::Io(e.to_string()))?;
649    writeln!(writer, "  2. json").map_err(|e| ScopeError::Io(e.to_string()))?;
650    writeln!(writer, "  3. csv").map_err(|e| ScopeError::Io(e.to_string()))?;
651    write!(writer, "Select [1-3, Enter for default]: ")
652        .map_err(|e| ScopeError::Io(e.to_string()))?;
653    writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
654
655    input.clear();
656    reader
657        .read_line(&mut input)
658        .map_err(|e| ScopeError::Io(e.to_string()))?;
659
660    match input.trim() {
661        "2" => {
662            new_config.output.format = OutputFormat::Json;
663            changes_made = true;
664        }
665        "3" => {
666            new_config.output.format = OutputFormat::Csv;
667            changes_made = true;
668        }
669        _ => {} // Keep default (table)
670    }
671
672    // Save configuration
673    if changes_made {
674        writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
675        writeln!(writer, "Saving configuration...").map_err(|e| ScopeError::Io(e.to_string()))?;
676        save_config_to_path(&new_config, config_path)?;
677        writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
678        writeln!(
679            writer,
680            "✓ Configuration saved to ~/.config/scope/config.yaml"
681        )
682        .map_err(|e| ScopeError::Io(e.to_string()))?;
683    } else {
684        writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
685        writeln!(writer, "No changes made.").map_err(|e| ScopeError::Io(e.to_string()))?;
686    }
687
688    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
689    writeln!(writer, "Setup complete! You can now use Scope.")
690        .map_err(|e| ScopeError::Io(e.to_string()))?;
691    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
692    writeln!(writer, "Quick start:").map_err(|e| ScopeError::Io(e.to_string()))?;
693    writeln!(writer, "  scope crawl USDC              # Analyze a token")
694        .map_err(|e| ScopeError::Io(e.to_string()))?;
695    writeln!(
696        writer,
697        "  scope address 0x...           # Analyze an address"
698    )
699    .map_err(|e| ScopeError::Io(e.to_string()))?;
700    writeln!(
701        writer,
702        "  scope insights <target>       # Auto-detect and analyze"
703    )
704    .map_err(|e| ScopeError::Io(e.to_string()))?;
705    writeln!(
706        writer,
707        "  scope monitor USDC            # Live TUI dashboard"
708    )
709    .map_err(|e| ScopeError::Io(e.to_string()))?;
710    writeln!(writer, "  scope interactive             # Interactive mode")
711        .map_err(|e| ScopeError::Io(e.to_string()))?;
712    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
713    writeln!(
714        writer,
715        "Run 'scope setup --status' to view your configuration."
716    )
717    .map_err(|e| ScopeError::Io(e.to_string()))?;
718    writeln!(
719        writer,
720        "Run 'scope completions zsh > _scope' for shell tab-completion."
721    )
722    .map_err(|e| ScopeError::Io(e.to_string()))?;
723    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
724
725    Ok(())
726}
727
728/// Testable implementation of prompt_optional_key with injected I/O.
729fn prompt_optional_key_impl(
730    reader: &mut impl BufRead,
731    writer: &mut impl Write,
732    name: &str,
733) -> Result<Option<String>> {
734    write!(writer, "  {} API key (or Enter to skip): ", name)
735        .map_err(|e| ScopeError::Io(e.to_string()))?;
736    writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
737
738    let mut input = String::new();
739    reader
740        .read_line(&mut input)
741        .map_err(|e| ScopeError::Io(e.to_string()))?;
742
743    let key = input.trim().to_string();
744    if key.is_empty() {
745        Ok(None)
746    } else {
747        Ok(Some(key))
748    }
749}
750
751/// Testable implementation of prompt_api_key with injected I/O.
752fn prompt_api_key_impl(
753    reader: &mut impl BufRead,
754    writer: &mut impl Write,
755    name: &str,
756) -> Result<String> {
757    write!(writer, "Enter {} API key: ", name).map_err(|e| ScopeError::Io(e.to_string()))?;
758    writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
759
760    let mut input = String::new();
761    reader
762        .read_line(&mut input)
763        .map_err(|e| ScopeError::Io(e.to_string()))?;
764
765    Ok(input.trim().to_string())
766}
767
768/// Saves the configuration to a specific path. Testable variant.
769fn save_config_to_path(config: &Config, config_path: &Path) -> Result<()> {
770    // Ensure directory exists
771    if let Some(parent) = config_path.parent() {
772        std::fs::create_dir_all(parent).map_err(|e| ScopeError::Io(e.to_string()))?;
773    }
774
775    // Build YAML manually for cleaner output
776    let mut yaml = String::new();
777    yaml.push_str("# Scope Configuration\n");
778    yaml.push_str("# Generated by 'scope setup'\n\n");
779
780    // Chains section
781    yaml.push_str("chains:\n");
782
783    // API keys
784    if !config.chains.api_keys.is_empty() {
785        yaml.push_str("  api_keys:\n");
786        for (name, key) in &config.chains.api_keys {
787            yaml.push_str(&format!("    {}: \"{}\"\n", name, key));
788        }
789    }
790
791    // RPC endpoints (if configured)
792    if let Some(ref rpc) = config.chains.ethereum_rpc {
793        yaml.push_str(&format!("  ethereum_rpc: \"{}\"\n", rpc));
794    }
795
796    // Output section
797    yaml.push_str("\noutput:\n");
798    yaml.push_str(&format!("  format: {}\n", config.output.format));
799    yaml.push_str(&format!("  color: {}\n", config.output.color));
800
801    // Ghola sidecar section
802    yaml.push_str("\nghola:\n");
803    yaml.push_str(&format!("  enabled: {}\n", config.ghola.enabled));
804    yaml.push_str(&format!("  stealth: {}\n", config.ghola.stealth));
805    yaml.push_str(&format!("  buffer_size: {}\n", config.ghola.buffer_size));
806
807    std::fs::write(config_path, yaml).map_err(|e| ScopeError::Io(e.to_string()))?;
808
809    Ok(())
810}
811
812// ============================================================================
813// Unit Tests
814// ============================================================================
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use tempfile::tempdir;
820
821    #[test]
822    fn test_mask_key_long() {
823        let masked = mask_key("ABCDEFGHIJKLMNOP");
824        assert_eq!(masked, "(ABCD...MNOP)");
825    }
826
827    #[test]
828    fn test_mask_key_short() {
829        let masked = mask_key("SHORT");
830        assert_eq!(masked, "*****");
831    }
832
833    #[test]
834    fn test_mask_key_exactly_8() {
835        let masked = mask_key("ABCDEFGH");
836        assert_eq!(masked, "********");
837    }
838
839    #[test]
840    fn test_mask_key_9_chars() {
841        let masked = mask_key("ABCDEFGHI");
842        assert_eq!(masked, "(ABCD...FGHI)");
843    }
844
845    #[test]
846    fn test_mask_key_empty() {
847        let masked = mask_key("");
848        assert_eq!(masked, "");
849    }
850
851    #[test]
852    fn test_get_api_key_url() {
853        assert!(get_api_key_url("etherscan").contains("etherscan.io"));
854        assert!(get_api_key_url("bscscan").contains("bscscan.com"));
855    }
856
857    // ========================================================================
858    // API key info tests
859    // ========================================================================
860
861    #[test]
862    fn test_get_api_key_info_all_providers() {
863        let providers = [
864            "etherscan",
865            "bscscan",
866            "polygonscan",
867            "arbiscan",
868            "basescan",
869            "optimism",
870        ];
871        for provider in providers {
872            let info = get_api_key_info(provider);
873            assert!(
874                !info.url.is_empty(),
875                "URL should not be empty for {}",
876                provider
877            );
878            assert!(
879                !info.chain.is_empty(),
880                "Chain should not be empty for {}",
881                provider
882            );
883            assert!(
884                !info.features.is_empty(),
885                "Features should not be empty for {}",
886                provider
887            );
888            assert!(
889                !info.signup_steps.is_empty(),
890                "Signup steps should not be empty for {}",
891                provider
892            );
893        }
894    }
895
896    #[test]
897    fn test_get_api_key_info_unknown() {
898        let info = get_api_key_info("unknown_provider");
899        // Should still return info, just generic
900        assert!(!info.url.is_empty());
901    }
902
903    #[test]
904    fn test_get_api_key_info_urls_correct() {
905        assert!(get_api_key_info("etherscan").url.contains("etherscan.io"));
906        assert!(get_api_key_info("bscscan").url.contains("bscscan.com"));
907        assert!(
908            get_api_key_info("polygonscan")
909                .url
910                .contains("polygonscan.com")
911        );
912        assert!(get_api_key_info("arbiscan").url.contains("arbiscan.io"));
913        assert!(get_api_key_info("basescan").url.contains("basescan.org"));
914        assert!(
915            get_api_key_info("optimism")
916                .url
917                .contains("optimistic.etherscan.io")
918        );
919    }
920
921    // ========================================================================
922    // Config items tests
923    // ========================================================================
924
925    #[test]
926    fn test_get_api_key_items_default_config() {
927        let config = Config::default();
928        let items = get_api_key_items(&config);
929        assert_eq!(items.len(), 6);
930        // All should be unset by default
931        for item in &items {
932            assert!(
933                !item.is_set,
934                "{} should not be set in default config",
935                item.name
936            );
937            assert!(item.value_hint.is_none());
938        }
939    }
940
941    #[test]
942    fn test_get_api_key_items_with_set_key() {
943        let mut config = Config::default();
944        config
945            .chains
946            .api_keys
947            .insert("etherscan".to_string(), "ABCDEFGHIJKLMNOP".to_string());
948        let items = get_api_key_items(&config);
949        let etherscan_item = items.iter().find(|i| i.name == "etherscan").unwrap();
950        assert!(etherscan_item.is_set);
951        assert!(etherscan_item.value_hint.is_some());
952        assert_eq!(etherscan_item.value_hint.as_ref().unwrap(), "(ABCD...MNOP)");
953    }
954
955    // ========================================================================
956    // SetupArgs tests
957    // ========================================================================
958
959    #[test]
960    fn test_setup_args_defaults() {
961        use clap::Parser;
962
963        #[derive(Parser)]
964        struct TestCli {
965            #[command(flatten)]
966            setup: SetupArgs,
967        }
968
969        let cli = TestCli::try_parse_from(["test"]).unwrap();
970        assert!(!cli.setup.status);
971        assert!(cli.setup.key.is_none());
972        assert!(!cli.setup.reset);
973    }
974
975    #[test]
976    fn test_setup_args_status() {
977        use clap::Parser;
978
979        #[derive(Parser)]
980        struct TestCli {
981            #[command(flatten)]
982            setup: SetupArgs,
983        }
984
985        let cli = TestCli::try_parse_from(["test", "--status"]).unwrap();
986        assert!(cli.setup.status);
987    }
988
989    #[test]
990    fn test_setup_args_key() {
991        use clap::Parser;
992
993        #[derive(Parser)]
994        struct TestCli {
995            #[command(flatten)]
996            setup: SetupArgs,
997        }
998
999        let cli = TestCli::try_parse_from(["test", "--key", "etherscan"]).unwrap();
1000        assert_eq!(cli.setup.key.as_deref(), Some("etherscan"));
1001    }
1002
1003    #[test]
1004    fn test_setup_args_reset() {
1005        use clap::Parser;
1006
1007        #[derive(Parser)]
1008        struct TestCli {
1009            #[command(flatten)]
1010            setup: SetupArgs,
1011        }
1012
1013        let cli = TestCli::try_parse_from(["test", "--reset"]).unwrap();
1014        assert!(cli.setup.reset);
1015    }
1016
1017    // ========================================================================
1018    // show_status (pure function, prints to stdout)
1019    // ========================================================================
1020
1021    #[test]
1022    fn test_show_status_no_panic() {
1023        let config = Config::default();
1024        show_status(&config);
1025    }
1026
1027    #[test]
1028    fn test_show_status_with_keys_no_panic() {
1029        let mut config = Config::default();
1030        config
1031            .chains
1032            .api_keys
1033            .insert("etherscan".to_string(), "abc123def456".to_string());
1034        config
1035            .chains
1036            .api_keys
1037            .insert("bscscan".to_string(), "xyz".to_string());
1038        show_status(&config);
1039    }
1040
1041    // ========================================================================
1042    // run() dispatching tests
1043    // ========================================================================
1044
1045    #[tokio::test]
1046    async fn test_run_status_mode() {
1047        let config = Config::default();
1048        let args = SetupArgs {
1049            status: true,
1050            key: None,
1051            reset: false,
1052        };
1053        let result = run(args, &config).await;
1054        assert!(result.is_ok());
1055    }
1056
1057    #[tokio::test]
1058    async fn test_run_key_unknown() {
1059        let config = Config::default();
1060        let args = SetupArgs {
1061            status: false,
1062            key: Some("nonexistent".to_string()),
1063            reset: false,
1064        };
1065        // This should print "Unknown API key" but still return Ok
1066        let result = run(args, &config).await;
1067        assert!(result.is_ok());
1068    }
1069
1070    // ========================================================================
1071    // save_config tests
1072    // ========================================================================
1073
1074    #[test]
1075    fn test_show_status_with_multiple_keys() {
1076        let mut config = Config::default();
1077        config
1078            .chains
1079            .api_keys
1080            .insert("etherscan".to_string(), "abc123def456789".to_string());
1081        config
1082            .chains
1083            .api_keys
1084            .insert("polygonscan".to_string(), "poly_key_12345".to_string());
1085        config
1086            .chains
1087            .api_keys
1088            .insert("bscscan".to_string(), "bsc".to_string()); // Short key
1089        show_status(&config);
1090    }
1091
1092    #[test]
1093    fn test_show_status_with_all_keys() {
1094        let mut config = Config::default();
1095        for key in [
1096            "etherscan",
1097            "bscscan",
1098            "polygonscan",
1099            "arbiscan",
1100            "basescan",
1101            "optimism",
1102        ] {
1103            config
1104                .chains
1105                .api_keys
1106                .insert(key.to_string(), format!("{}_key_12345678", key));
1107        }
1108        // No missing keys → should skip "where to get" section
1109        show_status(&config);
1110    }
1111
1112    #[test]
1113    fn test_show_status_with_custom_rpc() {
1114        let mut config = Config::default();
1115        config.chains.ethereum_rpc = Some("https://custom.rpc.example.com".to_string());
1116        config.output.format = OutputFormat::Json;
1117        config.output.color = false;
1118        show_status(&config);
1119    }
1120
1121    #[test]
1122    fn test_get_api_key_items_all_set() {
1123        let mut config = Config::default();
1124        for key in [
1125            "etherscan",
1126            "bscscan",
1127            "polygonscan",
1128            "arbiscan",
1129            "basescan",
1130            "optimism",
1131        ] {
1132            config
1133                .chains
1134                .api_keys
1135                .insert(key.to_string(), format!("{}_key_12345678", key));
1136        }
1137        let items = get_api_key_items(&config);
1138        assert_eq!(items.len(), 6);
1139        for item in &items {
1140            assert!(item.is_set, "{} should be set", item.name);
1141            assert!(item.value_hint.is_some());
1142        }
1143    }
1144
1145    #[test]
1146    fn test_get_api_key_info_features_not_empty() {
1147        for key in [
1148            "etherscan",
1149            "bscscan",
1150            "polygonscan",
1151            "arbiscan",
1152            "basescan",
1153            "optimism",
1154        ] {
1155            let info = get_api_key_info(key);
1156            assert!(!info.features.is_empty());
1157            assert!(!info.signup_steps.is_empty());
1158        }
1159    }
1160
1161    #[test]
1162    fn test_save_config_creates_file() {
1163        let tmp_dir = std::env::temp_dir().join("scope_test_setup");
1164        let _ = std::fs::create_dir_all(&tmp_dir);
1165        let tmp_file = tmp_dir.join("config.yaml");
1166
1167        // Since save_config uses Config::config_path(), we can't easily redirect it
1168        // but we can test the config serialization logic directly
1169        let mut config = Config::default();
1170        config
1171            .chains
1172            .api_keys
1173            .insert("etherscan".to_string(), "test_key_12345".to_string());
1174        config.output.format = OutputFormat::Json;
1175
1176        // Build the YAML manually (same logic as save_config)
1177        let mut yaml = String::new();
1178        yaml.push_str("# Scope Configuration\n");
1179        yaml.push_str("# Generated by 'scope setup'\n\n");
1180        yaml.push_str("chains:\n");
1181        if !config.chains.api_keys.is_empty() {
1182            yaml.push_str("  api_keys:\n");
1183            for (name, key) in &config.chains.api_keys {
1184                yaml.push_str(&format!("    {}: \"{}\"\n", name, key));
1185            }
1186        }
1187        yaml.push_str("\noutput:\n");
1188        yaml.push_str(&format!("  format: {}\n", config.output.format));
1189        yaml.push_str(&format!("  color: {}\n", config.output.color));
1190
1191        std::fs::write(&tmp_file, &yaml).unwrap();
1192        let content = std::fs::read_to_string(&tmp_file).unwrap();
1193        assert!(content.contains("etherscan"));
1194        assert!(content.contains("test_key_12345"));
1195        assert!(content.contains("json") || content.contains("Json"));
1196
1197        let _ = std::fs::remove_dir_all(&tmp_dir);
1198    }
1199
1200    #[test]
1201    fn test_save_config_to_temp_dir() {
1202        let temp_dir = tempfile::tempdir().unwrap();
1203        let config_path = temp_dir.path().join("scope").join("config.yaml");
1204
1205        // Create parent dirs
1206        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
1207
1208        let config = Config::default();
1209        let yaml = serde_yaml::to_string(&config.chains).unwrap();
1210        std::fs::write(&config_path, yaml).unwrap();
1211
1212        assert!(config_path.exists());
1213        let contents = std::fs::read_to_string(&config_path).unwrap();
1214        assert!(!contents.is_empty());
1215    }
1216
1217    #[test]
1218    fn test_setup_args_reset_flag() {
1219        let args = SetupArgs {
1220            status: false,
1221            key: None,
1222            reset: true,
1223        };
1224        assert!(args.reset);
1225    }
1226
1227    // ========================================================================
1228    // Refactored _impl function tests
1229    // ========================================================================
1230
1231    #[test]
1232    fn test_prompt_api_key_impl_with_input() {
1233        let input = b"MY_SECRET_API_KEY_123\n";
1234        let mut reader = std::io::Cursor::new(&input[..]);
1235        let mut writer = Vec::new();
1236
1237        let result = prompt_api_key_impl(&mut reader, &mut writer, "etherscan").unwrap();
1238        assert_eq!(result, "MY_SECRET_API_KEY_123");
1239        let output = String::from_utf8(writer).unwrap();
1240        assert!(output.contains("Enter etherscan API key"));
1241    }
1242
1243    #[test]
1244    fn test_prompt_api_key_impl_empty_input() {
1245        let input = b"\n";
1246        let mut reader = std::io::Cursor::new(&input[..]);
1247        let mut writer = Vec::new();
1248
1249        let result = prompt_api_key_impl(&mut reader, &mut writer, "bscscan").unwrap();
1250        assert_eq!(result, "");
1251    }
1252
1253    #[test]
1254    fn test_prompt_optional_key_impl_with_key() {
1255        let input = b"my_key_12345\n";
1256        let mut reader = std::io::Cursor::new(&input[..]);
1257        let mut writer = Vec::new();
1258
1259        let result = prompt_optional_key_impl(&mut reader, &mut writer, "polygonscan").unwrap();
1260        assert_eq!(result, Some("my_key_12345".to_string()));
1261    }
1262
1263    #[test]
1264    fn test_prompt_optional_key_impl_skip() {
1265        let input = b"\n";
1266        let mut reader = std::io::Cursor::new(&input[..]);
1267        let mut writer = Vec::new();
1268
1269        let result = prompt_optional_key_impl(&mut reader, &mut writer, "arbiscan").unwrap();
1270        assert_eq!(result, None);
1271        let output = String::from_utf8(writer).unwrap();
1272        assert!(output.contains("arbiscan API key"));
1273    }
1274
1275    #[test]
1276    fn test_save_config_to_path_creates_file_and_dirs() {
1277        let tmp = tempfile::tempdir().unwrap();
1278        let config_path = tmp.path().join("subdir").join("config.yaml");
1279        let mut config = Config::default();
1280        config
1281            .chains
1282            .api_keys
1283            .insert("etherscan".to_string(), "test_key_abc".to_string());
1284        config.output.format = OutputFormat::Json;
1285        config.output.color = false;
1286
1287        save_config_to_path(&config, &config_path).unwrap();
1288
1289        assert!(config_path.exists());
1290        let content = std::fs::read_to_string(&config_path).unwrap();
1291        assert!(content.contains("etherscan"));
1292        assert!(content.contains("test_key_abc"));
1293        assert!(content.contains("json"));
1294        assert!(content.contains("color: false"));
1295        assert!(content.contains("# Scope Configuration"));
1296    }
1297
1298    #[test]
1299    fn test_save_config_to_path_with_rpc() {
1300        let tmp = tempfile::tempdir().unwrap();
1301        let config_path = tmp.path().join("config.yaml");
1302        let mut config = Config::default();
1303        config.chains.ethereum_rpc = Some("https://my-rpc.example.com".to_string());
1304
1305        save_config_to_path(&config, &config_path).unwrap();
1306
1307        let content = std::fs::read_to_string(&config_path).unwrap();
1308        assert!(content.contains("ethereum_rpc"));
1309        assert!(content.contains("https://my-rpc.example.com"));
1310    }
1311
1312    #[test]
1313    fn test_reset_config_impl_confirm_yes() {
1314        let tmp = tempfile::tempdir().unwrap();
1315        let config_path = tmp.path().join("config.yaml");
1316        std::fs::write(&config_path, "test: data").unwrap();
1317
1318        let input = b"y\n";
1319        let mut reader = std::io::Cursor::new(&input[..]);
1320        let mut writer = Vec::new();
1321
1322        reset_config_impl(&mut reader, &mut writer, &config_path).unwrap();
1323        assert!(!config_path.exists());
1324        let output = String::from_utf8(writer).unwrap();
1325        assert!(output.contains("Configuration reset to defaults"));
1326    }
1327
1328    #[test]
1329    fn test_reset_config_impl_confirm_yes_full() {
1330        let tmp = tempfile::tempdir().unwrap();
1331        let config_path = tmp.path().join("config.yaml");
1332        std::fs::write(&config_path, "test: data").unwrap();
1333
1334        let input = b"yes\n";
1335        let mut reader = std::io::Cursor::new(&input[..]);
1336        let mut writer = Vec::new();
1337
1338        reset_config_impl(&mut reader, &mut writer, &config_path).unwrap();
1339        assert!(!config_path.exists());
1340    }
1341
1342    #[test]
1343    fn test_reset_config_impl_cancel() {
1344        let tmp = tempfile::tempdir().unwrap();
1345        let config_path = tmp.path().join("config.yaml");
1346        std::fs::write(&config_path, "test: data").unwrap();
1347
1348        let input = b"n\n";
1349        let mut reader = std::io::Cursor::new(&input[..]);
1350        let mut writer = Vec::new();
1351
1352        reset_config_impl(&mut reader, &mut writer, &config_path).unwrap();
1353        assert!(config_path.exists()); // Not deleted
1354        let output = String::from_utf8(writer).unwrap();
1355        assert!(output.contains("Cancelled"));
1356    }
1357
1358    #[test]
1359    fn test_reset_config_impl_no_file() {
1360        let tmp = tempfile::tempdir().unwrap();
1361        let config_path = tmp.path().join("nonexistent.yaml");
1362
1363        let input = b"";
1364        let mut reader = std::io::Cursor::new(&input[..]);
1365        let mut writer = Vec::new();
1366
1367        reset_config_impl(&mut reader, &mut writer, &config_path).unwrap();
1368        let output = String::from_utf8(writer).unwrap();
1369        assert!(output.contains("No configuration file found"));
1370    }
1371
1372    #[test]
1373    fn test_configure_single_key_impl_valid_key() {
1374        let tmp = tempfile::tempdir().unwrap();
1375        let config_path = tmp.path().join("config.yaml");
1376        let config = Config::default();
1377
1378        let input = b"MY_ETH_KEY_12345678\n";
1379        let mut reader = std::io::Cursor::new(&input[..]);
1380        let mut writer = Vec::new();
1381
1382        configure_single_key_impl(&mut reader, &mut writer, "etherscan", &config, &config_path)
1383            .unwrap();
1384
1385        let output = String::from_utf8(writer).unwrap();
1386        assert!(output.contains("Configure ETHERSCAN API Key"));
1387        assert!(output.contains("Ethereum Mainnet"));
1388        assert!(output.contains("etherscan API key saved"));
1389
1390        // Config file should be created
1391        assert!(config_path.exists());
1392        let content = std::fs::read_to_string(&config_path).unwrap();
1393        assert!(content.contains("MY_ETH_KEY_12345678"));
1394    }
1395
1396    #[test]
1397    fn test_configure_single_key_impl_empty_skips() {
1398        let tmp = tempfile::tempdir().unwrap();
1399        let config_path = tmp.path().join("config.yaml");
1400        let config = Config::default();
1401
1402        let input = b"\n";
1403        let mut reader = std::io::Cursor::new(&input[..]);
1404        let mut writer = Vec::new();
1405
1406        configure_single_key_impl(&mut reader, &mut writer, "etherscan", &config, &config_path)
1407            .unwrap();
1408
1409        let output = String::from_utf8(writer).unwrap();
1410        assert!(output.contains("Skipped"));
1411        assert!(!config_path.exists()); // No file created
1412    }
1413
1414    #[test]
1415    fn test_configure_single_key_impl_invalid_key_name() {
1416        let tmp = tempfile::tempdir().unwrap();
1417        let config_path = tmp.path().join("config.yaml");
1418        let config = Config::default();
1419
1420        let input = b"";
1421        let mut reader = std::io::Cursor::new(&input[..]);
1422        let mut writer = Vec::new();
1423
1424        configure_single_key_impl(&mut reader, &mut writer, "invalid", &config, &config_path)
1425            .unwrap();
1426
1427        let output = String::from_utf8(writer).unwrap();
1428        assert!(output.contains("Unknown API key: invalid"));
1429        assert!(output.contains("Valid options"));
1430    }
1431
1432    #[test]
1433    fn test_configure_single_key_impl_bscscan() {
1434        let tmp = tempfile::tempdir().unwrap();
1435        let config_path = tmp.path().join("config.yaml");
1436        let config = Config::default();
1437
1438        let input = b"BSC_KEY_ABCDEF\n";
1439        let mut reader = std::io::Cursor::new(&input[..]);
1440        let mut writer = Vec::new();
1441
1442        configure_single_key_impl(&mut reader, &mut writer, "bscscan", &config, &config_path)
1443            .unwrap();
1444
1445        let output = String::from_utf8(writer).unwrap();
1446        assert!(output.contains("Configure BSCSCAN API Key"));
1447        assert!(output.contains("BNB Smart Chain"));
1448        assert!(config_path.exists());
1449    }
1450
1451    #[test]
1452    fn test_wizard_no_changes() {
1453        let tmp = tempfile::tempdir().unwrap();
1454        let config_path = tmp.path().join("config.yaml");
1455        let config = Config::default();
1456
1457        // Skip etherscan, decline other chains, keep default format
1458        let input = b"\nn\n\n";
1459        let mut reader = std::io::Cursor::new(&input[..]);
1460        let mut writer = Vec::new();
1461
1462        run_setup_wizard_impl(&mut reader, &mut writer, &config, &config_path).unwrap();
1463
1464        let output = String::from_utf8(writer).unwrap();
1465        assert!(output.contains("Scope Setup Wizard"));
1466        assert!(output.contains("Step 1: API Keys"));
1467        assert!(output.contains("Step 2: Preferences"));
1468        assert!(output.contains("No changes made"));
1469        assert!(output.contains("Setup complete"));
1470        assert!(!config_path.exists()); // No config saved
1471    }
1472
1473    #[test]
1474    fn test_wizard_with_etherscan_key_and_json_format() {
1475        let tmp = tempfile::tempdir().unwrap();
1476        let config_path = tmp.path().join("config.yaml");
1477        let config = Config::default();
1478
1479        // Provide etherscan key, decline other chains, select JSON format (2)
1480        let input = b"MY_ETH_KEY\nn\n2\n";
1481        let mut reader = std::io::Cursor::new(&input[..]);
1482        let mut writer = Vec::new();
1483
1484        run_setup_wizard_impl(&mut reader, &mut writer, &config, &config_path).unwrap();
1485
1486        let output = String::from_utf8(writer).unwrap();
1487        assert!(output.contains("Configuration saved"));
1488        assert!(config_path.exists());
1489        let content = std::fs::read_to_string(&config_path).unwrap();
1490        assert!(content.contains("MY_ETH_KEY"));
1491        assert!(content.contains("json"));
1492    }
1493
1494    #[test]
1495    fn test_wizard_with_csv_format() {
1496        let tmp = tempfile::tempdir().unwrap();
1497        let config_path = tmp.path().join("config.yaml");
1498        let config = Config::default();
1499
1500        // Skip etherscan, decline other chains, select CSV format (3)
1501        let input = b"\nn\n3\n";
1502        let mut reader = std::io::Cursor::new(&input[..]);
1503        let mut writer = Vec::new();
1504
1505        run_setup_wizard_impl(&mut reader, &mut writer, &config, &config_path).unwrap();
1506
1507        let output = String::from_utf8(writer).unwrap();
1508        assert!(output.contains("Configuration saved"));
1509        let content = std::fs::read_to_string(&config_path).unwrap();
1510        assert!(content.contains("csv"));
1511    }
1512
1513    #[test]
1514    fn test_wizard_with_other_chains_yes() {
1515        let tmp = tempfile::tempdir().unwrap();
1516        let config_path = tmp.path().join("config.yaml");
1517        let config = Config::default();
1518
1519        // Skip etherscan, say yes to other chains, provide bscscan key, skip rest, keep default format
1520        let input = b"\ny\nBSC_KEY_123\n\n\n\n\n\n";
1521        let mut reader = std::io::Cursor::new(&input[..]);
1522        let mut writer = Vec::new();
1523
1524        run_setup_wizard_impl(&mut reader, &mut writer, &config, &config_path).unwrap();
1525
1526        let output = String::from_utf8(writer).unwrap();
1527        assert!(output.contains("BSCSCAN API KEY"));
1528        assert!(output.contains("Configuration saved"));
1529        let content = std::fs::read_to_string(&config_path).unwrap();
1530        assert!(content.contains("BSC_KEY_123"));
1531    }
1532
1533    #[test]
1534    fn test_wizard_etherscan_already_configured() {
1535        let tmp = tempfile::tempdir().unwrap();
1536        let config_path = tmp.path().join("config.yaml");
1537        let mut config = Config::default();
1538        config
1539            .chains
1540            .api_keys
1541            .insert("etherscan".to_string(), "existing_key".to_string());
1542
1543        // Decline other chains, keep default format
1544        let input = b"n\n\n";
1545        let mut reader = std::io::Cursor::new(&input[..]);
1546        let mut writer = Vec::new();
1547
1548        run_setup_wizard_impl(&mut reader, &mut writer, &config, &config_path).unwrap();
1549
1550        let output = String::from_utf8(writer).unwrap();
1551        assert!(output.contains("Etherscan API key already configured"));
1552        assert!(output.contains("No changes made"));
1553    }
1554
1555    #[test]
1556    fn test_save_config_includes_ghola_section() {
1557        let dir = tempdir().unwrap();
1558        let config_path = dir.path().join("config.yaml");
1559
1560        let mut config = Config::default();
1561        config.ghola.enabled = true;
1562        config.ghola.stealth = true;
1563        config.ghola.buffer_size = 8192;
1564
1565        save_config_to_path(&config, &config_path).unwrap();
1566
1567        let contents = std::fs::read_to_string(&config_path).unwrap();
1568        assert!(contents.contains("ghola:"));
1569        assert!(contents.contains("enabled: true"));
1570        assert!(contents.contains("stealth: true"));
1571        assert!(contents.contains("buffer_size: 8192"));
1572    }
1573
1574    #[test]
1575    fn test_save_config_ghola_defaults() {
1576        let dir = tempdir().unwrap();
1577        let config_path = dir.path().join("config.yaml");
1578
1579        let config = Config::default();
1580        save_config_to_path(&config, &config_path).unwrap();
1581
1582        let contents = std::fs::read_to_string(&config_path).unwrap();
1583        assert!(contents.contains("ghola:"));
1584        assert!(contents.contains("enabled: false"));
1585        assert!(contents.contains("stealth: false"));
1586        assert!(contents.contains("buffer_size: 4096"));
1587    }
1588
1589    #[test]
1590    fn test_which_ghola_returns_bool() {
1591        let result = which_ghola();
1592        assert!(result == true || result == false);
1593    }
1594
1595    #[test]
1596    fn test_show_status_ghola_disabled() {
1597        let config = Config::default();
1598        // Just verify it doesn't panic
1599        show_status(&config);
1600    }
1601
1602    #[test]
1603    fn test_show_status_ghola_enabled_stealth_on() {
1604        let mut config = Config::default();
1605        config.ghola.enabled = true;
1606        config.ghola.stealth = true;
1607        show_status(&config);
1608    }
1609
1610    #[test]
1611    fn test_show_status_ghola_enabled_stealth_off() {
1612        let mut config = Config::default();
1613        config.ghola.enabled = true;
1614        config.ghola.stealth = false;
1615        show_status(&config);
1616    }
1617}