Skip to main content

scope/cli/
setup.rs

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