Skip to main content

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