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