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