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