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//! bca setup
12//!
13//! # Show current configuration status
14//! bca setup --status
15//!
16//! # Set a specific API key
17//! bca 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!(writer, "  scope interactive             # Interactive mode")
630        .map_err(|e| ScopeError::Io(e.to_string()))?;
631    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
632    writeln!(
633        writer,
634        "Run 'scope setup --status' to view your configuration."
635    )
636    .map_err(|e| ScopeError::Io(e.to_string()))?;
637    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
638
639    Ok(())
640}
641
642/// Testable implementation of prompt_optional_key with injected I/O.
643fn prompt_optional_key_impl(
644    reader: &mut impl BufRead,
645    writer: &mut impl Write,
646    name: &str,
647) -> Result<Option<String>> {
648    write!(writer, "  {} API key (or Enter to skip): ", name)
649        .map_err(|e| ScopeError::Io(e.to_string()))?;
650    writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
651
652    let mut input = String::new();
653    reader
654        .read_line(&mut input)
655        .map_err(|e| ScopeError::Io(e.to_string()))?;
656
657    let key = input.trim().to_string();
658    if key.is_empty() {
659        Ok(None)
660    } else {
661        Ok(Some(key))
662    }
663}
664
665/// Testable implementation of prompt_api_key with injected I/O.
666fn prompt_api_key_impl(
667    reader: &mut impl BufRead,
668    writer: &mut impl Write,
669    name: &str,
670) -> Result<String> {
671    write!(writer, "Enter {} API key: ", name).map_err(|e| ScopeError::Io(e.to_string()))?;
672    writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
673
674    let mut input = String::new();
675    reader
676        .read_line(&mut input)
677        .map_err(|e| ScopeError::Io(e.to_string()))?;
678
679    Ok(input.trim().to_string())
680}
681
682/// Saves the configuration to a specific path. Testable variant.
683fn save_config_to_path(config: &Config, config_path: &Path) -> Result<()> {
684    // Ensure directory exists
685    if let Some(parent) = config_path.parent() {
686        std::fs::create_dir_all(parent).map_err(|e| ScopeError::Io(e.to_string()))?;
687    }
688
689    // Build YAML manually for cleaner output
690    let mut yaml = String::new();
691    yaml.push_str("# Scope Configuration\n");
692    yaml.push_str("# Generated by 'bca setup'\n\n");
693
694    // Chains section
695    yaml.push_str("chains:\n");
696
697    // API keys
698    if !config.chains.api_keys.is_empty() {
699        yaml.push_str("  api_keys:\n");
700        for (name, key) in &config.chains.api_keys {
701            yaml.push_str(&format!("    {}: \"{}\"\n", name, key));
702        }
703    }
704
705    // RPC endpoints (if configured)
706    if let Some(ref rpc) = config.chains.ethereum_rpc {
707        yaml.push_str(&format!("  ethereum_rpc: \"{}\"\n", rpc));
708    }
709
710    // Output section
711    yaml.push_str("\noutput:\n");
712    yaml.push_str(&format!("  format: {}\n", config.output.format));
713    yaml.push_str(&format!("  color: {}\n", config.output.color));
714
715    std::fs::write(config_path, yaml).map_err(|e| ScopeError::Io(e.to_string()))?;
716
717    Ok(())
718}
719
720// ============================================================================
721// Unit Tests
722// ============================================================================
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    #[test]
729    fn test_mask_key_long() {
730        let masked = mask_key("ABCDEFGHIJKLMNOP");
731        assert_eq!(masked, "(ABCD...MNOP)");
732    }
733
734    #[test]
735    fn test_mask_key_short() {
736        let masked = mask_key("SHORT");
737        assert_eq!(masked, "*****");
738    }
739
740    #[test]
741    fn test_mask_key_exactly_8() {
742        let masked = mask_key("ABCDEFGH");
743        assert_eq!(masked, "********");
744    }
745
746    #[test]
747    fn test_mask_key_9_chars() {
748        let masked = mask_key("ABCDEFGHI");
749        assert_eq!(masked, "(ABCD...FGHI)");
750    }
751
752    #[test]
753    fn test_mask_key_empty() {
754        let masked = mask_key("");
755        assert_eq!(masked, "");
756    }
757
758    #[test]
759    fn test_get_api_key_url() {
760        assert!(get_api_key_url("etherscan").contains("etherscan.io"));
761        assert!(get_api_key_url("bscscan").contains("bscscan.com"));
762    }
763
764    // ========================================================================
765    // API key info tests
766    // ========================================================================
767
768    #[test]
769    fn test_get_api_key_info_all_providers() {
770        let providers = [
771            "etherscan",
772            "bscscan",
773            "polygonscan",
774            "arbiscan",
775            "basescan",
776            "optimism",
777        ];
778        for provider in providers {
779            let info = get_api_key_info(provider);
780            assert!(
781                !info.url.is_empty(),
782                "URL should not be empty for {}",
783                provider
784            );
785            assert!(
786                !info.chain.is_empty(),
787                "Chain should not be empty for {}",
788                provider
789            );
790            assert!(
791                !info.features.is_empty(),
792                "Features should not be empty for {}",
793                provider
794            );
795            assert!(
796                !info.signup_steps.is_empty(),
797                "Signup steps should not be empty for {}",
798                provider
799            );
800        }
801    }
802
803    #[test]
804    fn test_get_api_key_info_unknown() {
805        let info = get_api_key_info("unknown_provider");
806        // Should still return info, just generic
807        assert!(!info.url.is_empty());
808    }
809
810    #[test]
811    fn test_get_api_key_info_urls_correct() {
812        assert!(get_api_key_info("etherscan").url.contains("etherscan.io"));
813        assert!(get_api_key_info("bscscan").url.contains("bscscan.com"));
814        assert!(
815            get_api_key_info("polygonscan")
816                .url
817                .contains("polygonscan.com")
818        );
819        assert!(get_api_key_info("arbiscan").url.contains("arbiscan.io"));
820        assert!(get_api_key_info("basescan").url.contains("basescan.org"));
821        assert!(
822            get_api_key_info("optimism")
823                .url
824                .contains("optimistic.etherscan.io")
825        );
826    }
827
828    // ========================================================================
829    // Config items tests
830    // ========================================================================
831
832    #[test]
833    fn test_get_api_key_items_default_config() {
834        let config = Config::default();
835        let items = get_api_key_items(&config);
836        assert_eq!(items.len(), 6);
837        // All should be unset by default
838        for item in &items {
839            assert!(
840                !item.is_set,
841                "{} should not be set in default config",
842                item.name
843            );
844            assert!(item.value_hint.is_none());
845        }
846    }
847
848    #[test]
849    fn test_get_api_key_items_with_set_key() {
850        let mut config = Config::default();
851        config
852            .chains
853            .api_keys
854            .insert("etherscan".to_string(), "ABCDEFGHIJKLMNOP".to_string());
855        let items = get_api_key_items(&config);
856        let etherscan_item = items.iter().find(|i| i.name == "etherscan").unwrap();
857        assert!(etherscan_item.is_set);
858        assert!(etherscan_item.value_hint.is_some());
859        assert_eq!(etherscan_item.value_hint.as_ref().unwrap(), "(ABCD...MNOP)");
860    }
861
862    // ========================================================================
863    // SetupArgs tests
864    // ========================================================================
865
866    #[test]
867    fn test_setup_args_defaults() {
868        use clap::Parser;
869
870        #[derive(Parser)]
871        struct TestCli {
872            #[command(flatten)]
873            setup: SetupArgs,
874        }
875
876        let cli = TestCli::try_parse_from(["test"]).unwrap();
877        assert!(!cli.setup.status);
878        assert!(cli.setup.key.is_none());
879        assert!(!cli.setup.reset);
880    }
881
882    #[test]
883    fn test_setup_args_status() {
884        use clap::Parser;
885
886        #[derive(Parser)]
887        struct TestCli {
888            #[command(flatten)]
889            setup: SetupArgs,
890        }
891
892        let cli = TestCli::try_parse_from(["test", "--status"]).unwrap();
893        assert!(cli.setup.status);
894    }
895
896    #[test]
897    fn test_setup_args_key() {
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", "--key", "etherscan"]).unwrap();
907        assert_eq!(cli.setup.key.as_deref(), Some("etherscan"));
908    }
909
910    #[test]
911    fn test_setup_args_reset() {
912        use clap::Parser;
913
914        #[derive(Parser)]
915        struct TestCli {
916            #[command(flatten)]
917            setup: SetupArgs,
918        }
919
920        let cli = TestCli::try_parse_from(["test", "--reset"]).unwrap();
921        assert!(cli.setup.reset);
922    }
923
924    // ========================================================================
925    // show_status (pure function, prints to stdout)
926    // ========================================================================
927
928    #[test]
929    fn test_show_status_no_panic() {
930        let config = Config::default();
931        show_status(&config);
932    }
933
934    #[test]
935    fn test_show_status_with_keys_no_panic() {
936        let mut config = Config::default();
937        config
938            .chains
939            .api_keys
940            .insert("etherscan".to_string(), "abc123def456".to_string());
941        config
942            .chains
943            .api_keys
944            .insert("bscscan".to_string(), "xyz".to_string());
945        show_status(&config);
946    }
947
948    // ========================================================================
949    // run() dispatching tests
950    // ========================================================================
951
952    #[tokio::test]
953    async fn test_run_status_mode() {
954        let config = Config::default();
955        let args = SetupArgs {
956            status: true,
957            key: None,
958            reset: false,
959        };
960        let result = run(args, &config).await;
961        assert!(result.is_ok());
962    }
963
964    #[tokio::test]
965    async fn test_run_key_unknown() {
966        let config = Config::default();
967        let args = SetupArgs {
968            status: false,
969            key: Some("nonexistent".to_string()),
970            reset: false,
971        };
972        // This should print "Unknown API key" but still return Ok
973        let result = run(args, &config).await;
974        assert!(result.is_ok());
975    }
976
977    // ========================================================================
978    // save_config tests
979    // ========================================================================
980
981    #[test]
982    fn test_show_status_with_multiple_keys() {
983        let mut config = Config::default();
984        config
985            .chains
986            .api_keys
987            .insert("etherscan".to_string(), "abc123def456789".to_string());
988        config
989            .chains
990            .api_keys
991            .insert("polygonscan".to_string(), "poly_key_12345".to_string());
992        config
993            .chains
994            .api_keys
995            .insert("bscscan".to_string(), "bsc".to_string()); // Short key
996        show_status(&config);
997    }
998
999    #[test]
1000    fn test_show_status_with_all_keys() {
1001        let mut config = Config::default();
1002        for key in [
1003            "etherscan",
1004            "bscscan",
1005            "polygonscan",
1006            "arbiscan",
1007            "basescan",
1008            "optimism",
1009        ] {
1010            config
1011                .chains
1012                .api_keys
1013                .insert(key.to_string(), format!("{}_key_12345678", key));
1014        }
1015        // No missing keys → should skip "where to get" section
1016        show_status(&config);
1017    }
1018
1019    #[test]
1020    fn test_show_status_with_custom_rpc() {
1021        let mut config = Config::default();
1022        config.chains.ethereum_rpc = Some("https://custom.rpc.example.com".to_string());
1023        config.output.format = OutputFormat::Json;
1024        config.output.color = false;
1025        show_status(&config);
1026    }
1027
1028    #[test]
1029    fn test_get_api_key_items_all_set() {
1030        let mut config = Config::default();
1031        for key in [
1032            "etherscan",
1033            "bscscan",
1034            "polygonscan",
1035            "arbiscan",
1036            "basescan",
1037            "optimism",
1038        ] {
1039            config
1040                .chains
1041                .api_keys
1042                .insert(key.to_string(), format!("{}_key_12345678", key));
1043        }
1044        let items = get_api_key_items(&config);
1045        assert_eq!(items.len(), 6);
1046        for item in &items {
1047            assert!(item.is_set, "{} should be set", item.name);
1048            assert!(item.value_hint.is_some());
1049        }
1050    }
1051
1052    #[test]
1053    fn test_get_api_key_info_features_not_empty() {
1054        for key in [
1055            "etherscan",
1056            "bscscan",
1057            "polygonscan",
1058            "arbiscan",
1059            "basescan",
1060            "optimism",
1061        ] {
1062            let info = get_api_key_info(key);
1063            assert!(!info.features.is_empty());
1064            assert!(!info.signup_steps.is_empty());
1065        }
1066    }
1067
1068    #[test]
1069    fn test_save_config_creates_file() {
1070        let tmp_dir = std::env::temp_dir().join("scope_test_setup");
1071        let _ = std::fs::create_dir_all(&tmp_dir);
1072        let tmp_file = tmp_dir.join("config.yaml");
1073
1074        // Since save_config uses Config::config_path(), we can't easily redirect it
1075        // but we can test the config serialization logic directly
1076        let mut config = Config::default();
1077        config
1078            .chains
1079            .api_keys
1080            .insert("etherscan".to_string(), "test_key_12345".to_string());
1081        config.output.format = OutputFormat::Json;
1082
1083        // Build the YAML manually (same logic as save_config)
1084        let mut yaml = String::new();
1085        yaml.push_str("# Scope Configuration\n");
1086        yaml.push_str("# Generated by 'bca setup'\n\n");
1087        yaml.push_str("chains:\n");
1088        if !config.chains.api_keys.is_empty() {
1089            yaml.push_str("  api_keys:\n");
1090            for (name, key) in &config.chains.api_keys {
1091                yaml.push_str(&format!("    {}: \"{}\"\n", name, key));
1092            }
1093        }
1094        yaml.push_str("\noutput:\n");
1095        yaml.push_str(&format!("  format: {}\n", config.output.format));
1096        yaml.push_str(&format!("  color: {}\n", config.output.color));
1097
1098        std::fs::write(&tmp_file, &yaml).unwrap();
1099        let content = std::fs::read_to_string(&tmp_file).unwrap();
1100        assert!(content.contains("etherscan"));
1101        assert!(content.contains("test_key_12345"));
1102        assert!(content.contains("json") || content.contains("Json"));
1103
1104        let _ = std::fs::remove_dir_all(&tmp_dir);
1105    }
1106
1107    #[test]
1108    fn test_save_config_to_temp_dir() {
1109        let temp_dir = tempfile::tempdir().unwrap();
1110        let config_path = temp_dir.path().join("scope").join("config.yaml");
1111
1112        // Create parent dirs
1113        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
1114
1115        let config = Config::default();
1116        let yaml = serde_yaml::to_string(&config.chains).unwrap();
1117        std::fs::write(&config_path, yaml).unwrap();
1118
1119        assert!(config_path.exists());
1120        let contents = std::fs::read_to_string(&config_path).unwrap();
1121        assert!(!contents.is_empty());
1122    }
1123
1124    #[test]
1125    fn test_setup_args_reset_flag() {
1126        let args = SetupArgs {
1127            status: false,
1128            key: None,
1129            reset: true,
1130        };
1131        assert!(args.reset);
1132    }
1133
1134    // ========================================================================
1135    // Refactored _impl function tests
1136    // ========================================================================
1137
1138    #[test]
1139    fn test_prompt_api_key_impl_with_input() {
1140        let input = b"MY_SECRET_API_KEY_123\n";
1141        let mut reader = std::io::Cursor::new(&input[..]);
1142        let mut writer = Vec::new();
1143
1144        let result = prompt_api_key_impl(&mut reader, &mut writer, "etherscan").unwrap();
1145        assert_eq!(result, "MY_SECRET_API_KEY_123");
1146        let output = String::from_utf8(writer).unwrap();
1147        assert!(output.contains("Enter etherscan API key"));
1148    }
1149
1150    #[test]
1151    fn test_prompt_api_key_impl_empty_input() {
1152        let input = b"\n";
1153        let mut reader = std::io::Cursor::new(&input[..]);
1154        let mut writer = Vec::new();
1155
1156        let result = prompt_api_key_impl(&mut reader, &mut writer, "bscscan").unwrap();
1157        assert_eq!(result, "");
1158    }
1159
1160    #[test]
1161    fn test_prompt_optional_key_impl_with_key() {
1162        let input = b"my_key_12345\n";
1163        let mut reader = std::io::Cursor::new(&input[..]);
1164        let mut writer = Vec::new();
1165
1166        let result = prompt_optional_key_impl(&mut reader, &mut writer, "polygonscan").unwrap();
1167        assert_eq!(result, Some("my_key_12345".to_string()));
1168    }
1169
1170    #[test]
1171    fn test_prompt_optional_key_impl_skip() {
1172        let input = b"\n";
1173        let mut reader = std::io::Cursor::new(&input[..]);
1174        let mut writer = Vec::new();
1175
1176        let result = prompt_optional_key_impl(&mut reader, &mut writer, "arbiscan").unwrap();
1177        assert_eq!(result, None);
1178        let output = String::from_utf8(writer).unwrap();
1179        assert!(output.contains("arbiscan API key"));
1180    }
1181
1182    #[test]
1183    fn test_save_config_to_path_creates_file_and_dirs() {
1184        let tmp = tempfile::tempdir().unwrap();
1185        let config_path = tmp.path().join("subdir").join("config.yaml");
1186        let mut config = Config::default();
1187        config
1188            .chains
1189            .api_keys
1190            .insert("etherscan".to_string(), "test_key_abc".to_string());
1191        config.output.format = OutputFormat::Json;
1192        config.output.color = false;
1193
1194        save_config_to_path(&config, &config_path).unwrap();
1195
1196        assert!(config_path.exists());
1197        let content = std::fs::read_to_string(&config_path).unwrap();
1198        assert!(content.contains("etherscan"));
1199        assert!(content.contains("test_key_abc"));
1200        assert!(content.contains("json"));
1201        assert!(content.contains("color: false"));
1202        assert!(content.contains("# Scope Configuration"));
1203    }
1204
1205    #[test]
1206    fn test_save_config_to_path_with_rpc() {
1207        let tmp = tempfile::tempdir().unwrap();
1208        let config_path = tmp.path().join("config.yaml");
1209        let mut config = Config::default();
1210        config.chains.ethereum_rpc = Some("https://my-rpc.example.com".to_string());
1211
1212        save_config_to_path(&config, &config_path).unwrap();
1213
1214        let content = std::fs::read_to_string(&config_path).unwrap();
1215        assert!(content.contains("ethereum_rpc"));
1216        assert!(content.contains("https://my-rpc.example.com"));
1217    }
1218
1219    #[test]
1220    fn test_reset_config_impl_confirm_yes() {
1221        let tmp = tempfile::tempdir().unwrap();
1222        let config_path = tmp.path().join("config.yaml");
1223        std::fs::write(&config_path, "test: data").unwrap();
1224
1225        let input = b"y\n";
1226        let mut reader = std::io::Cursor::new(&input[..]);
1227        let mut writer = Vec::new();
1228
1229        reset_config_impl(&mut reader, &mut writer, &config_path).unwrap();
1230        assert!(!config_path.exists());
1231        let output = String::from_utf8(writer).unwrap();
1232        assert!(output.contains("Configuration reset to defaults"));
1233    }
1234
1235    #[test]
1236    fn test_reset_config_impl_confirm_yes_full() {
1237        let tmp = tempfile::tempdir().unwrap();
1238        let config_path = tmp.path().join("config.yaml");
1239        std::fs::write(&config_path, "test: data").unwrap();
1240
1241        let input = b"yes\n";
1242        let mut reader = std::io::Cursor::new(&input[..]);
1243        let mut writer = Vec::new();
1244
1245        reset_config_impl(&mut reader, &mut writer, &config_path).unwrap();
1246        assert!(!config_path.exists());
1247    }
1248
1249    #[test]
1250    fn test_reset_config_impl_cancel() {
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"n\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()); // Not deleted
1261        let output = String::from_utf8(writer).unwrap();
1262        assert!(output.contains("Cancelled"));
1263    }
1264
1265    #[test]
1266    fn test_reset_config_impl_no_file() {
1267        let tmp = tempfile::tempdir().unwrap();
1268        let config_path = tmp.path().join("nonexistent.yaml");
1269
1270        let input = b"";
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        let output = String::from_utf8(writer).unwrap();
1276        assert!(output.contains("No configuration file found"));
1277    }
1278
1279    #[test]
1280    fn test_configure_single_key_impl_valid_key() {
1281        let tmp = tempfile::tempdir().unwrap();
1282        let config_path = tmp.path().join("config.yaml");
1283        let config = Config::default();
1284
1285        let input = b"MY_ETH_KEY_12345678\n";
1286        let mut reader = std::io::Cursor::new(&input[..]);
1287        let mut writer = Vec::new();
1288
1289        configure_single_key_impl(&mut reader, &mut writer, "etherscan", &config, &config_path)
1290            .unwrap();
1291
1292        let output = String::from_utf8(writer).unwrap();
1293        assert!(output.contains("Configure ETHERSCAN API Key"));
1294        assert!(output.contains("Ethereum Mainnet"));
1295        assert!(output.contains("etherscan API key saved"));
1296
1297        // Config file should be created
1298        assert!(config_path.exists());
1299        let content = std::fs::read_to_string(&config_path).unwrap();
1300        assert!(content.contains("MY_ETH_KEY_12345678"));
1301    }
1302
1303    #[test]
1304    fn test_configure_single_key_impl_empty_skips() {
1305        let tmp = tempfile::tempdir().unwrap();
1306        let config_path = tmp.path().join("config.yaml");
1307        let config = Config::default();
1308
1309        let input = b"\n";
1310        let mut reader = std::io::Cursor::new(&input[..]);
1311        let mut writer = Vec::new();
1312
1313        configure_single_key_impl(&mut reader, &mut writer, "etherscan", &config, &config_path)
1314            .unwrap();
1315
1316        let output = String::from_utf8(writer).unwrap();
1317        assert!(output.contains("Skipped"));
1318        assert!(!config_path.exists()); // No file created
1319    }
1320
1321    #[test]
1322    fn test_configure_single_key_impl_invalid_key_name() {
1323        let tmp = tempfile::tempdir().unwrap();
1324        let config_path = tmp.path().join("config.yaml");
1325        let config = Config::default();
1326
1327        let input = b"";
1328        let mut reader = std::io::Cursor::new(&input[..]);
1329        let mut writer = Vec::new();
1330
1331        configure_single_key_impl(&mut reader, &mut writer, "invalid", &config, &config_path)
1332            .unwrap();
1333
1334        let output = String::from_utf8(writer).unwrap();
1335        assert!(output.contains("Unknown API key: invalid"));
1336        assert!(output.contains("Valid options"));
1337    }
1338
1339    #[test]
1340    fn test_configure_single_key_impl_bscscan() {
1341        let tmp = tempfile::tempdir().unwrap();
1342        let config_path = tmp.path().join("config.yaml");
1343        let config = Config::default();
1344
1345        let input = b"BSC_KEY_ABCDEF\n";
1346        let mut reader = std::io::Cursor::new(&input[..]);
1347        let mut writer = Vec::new();
1348
1349        configure_single_key_impl(&mut reader, &mut writer, "bscscan", &config, &config_path)
1350            .unwrap();
1351
1352        let output = String::from_utf8(writer).unwrap();
1353        assert!(output.contains("Configure BSCSCAN API Key"));
1354        assert!(output.contains("BNB Smart Chain"));
1355        assert!(config_path.exists());
1356    }
1357
1358    #[test]
1359    fn test_wizard_no_changes() {
1360        let tmp = tempfile::tempdir().unwrap();
1361        let config_path = tmp.path().join("config.yaml");
1362        let config = Config::default();
1363
1364        // Skip etherscan, decline other chains, keep default format
1365        let input = b"\nn\n\n";
1366        let mut reader = std::io::Cursor::new(&input[..]);
1367        let mut writer = Vec::new();
1368
1369        run_setup_wizard_impl(&mut reader, &mut writer, &config, &config_path).unwrap();
1370
1371        let output = String::from_utf8(writer).unwrap();
1372        assert!(output.contains("Scope Setup Wizard"));
1373        assert!(output.contains("Step 1: API Keys"));
1374        assert!(output.contains("Step 2: Preferences"));
1375        assert!(output.contains("No changes made"));
1376        assert!(output.contains("Setup complete"));
1377        assert!(!config_path.exists()); // No config saved
1378    }
1379
1380    #[test]
1381    fn test_wizard_with_etherscan_key_and_json_format() {
1382        let tmp = tempfile::tempdir().unwrap();
1383        let config_path = tmp.path().join("config.yaml");
1384        let config = Config::default();
1385
1386        // Provide etherscan key, decline other chains, select JSON format (2)
1387        let input = b"MY_ETH_KEY\nn\n2\n";
1388        let mut reader = std::io::Cursor::new(&input[..]);
1389        let mut writer = Vec::new();
1390
1391        run_setup_wizard_impl(&mut reader, &mut writer, &config, &config_path).unwrap();
1392
1393        let output = String::from_utf8(writer).unwrap();
1394        assert!(output.contains("Configuration saved"));
1395        assert!(config_path.exists());
1396        let content = std::fs::read_to_string(&config_path).unwrap();
1397        assert!(content.contains("MY_ETH_KEY"));
1398        assert!(content.contains("json"));
1399    }
1400
1401    #[test]
1402    fn test_wizard_with_csv_format() {
1403        let tmp = tempfile::tempdir().unwrap();
1404        let config_path = tmp.path().join("config.yaml");
1405        let config = Config::default();
1406
1407        // Skip etherscan, decline other chains, select CSV format (3)
1408        let input = b"\nn\n3\n";
1409        let mut reader = std::io::Cursor::new(&input[..]);
1410        let mut writer = Vec::new();
1411
1412        run_setup_wizard_impl(&mut reader, &mut writer, &config, &config_path).unwrap();
1413
1414        let output = String::from_utf8(writer).unwrap();
1415        assert!(output.contains("Configuration saved"));
1416        let content = std::fs::read_to_string(&config_path).unwrap();
1417        assert!(content.contains("csv"));
1418    }
1419
1420    #[test]
1421    fn test_wizard_with_other_chains_yes() {
1422        let tmp = tempfile::tempdir().unwrap();
1423        let config_path = tmp.path().join("config.yaml");
1424        let config = Config::default();
1425
1426        // Skip etherscan, say yes to other chains, provide bscscan key, skip rest, keep default format
1427        let input = b"\ny\nBSC_KEY_123\n\n\n\n\n\n";
1428        let mut reader = std::io::Cursor::new(&input[..]);
1429        let mut writer = Vec::new();
1430
1431        run_setup_wizard_impl(&mut reader, &mut writer, &config, &config_path).unwrap();
1432
1433        let output = String::from_utf8(writer).unwrap();
1434        assert!(output.contains("BSCSCAN API KEY"));
1435        assert!(output.contains("Configuration saved"));
1436        let content = std::fs::read_to_string(&config_path).unwrap();
1437        assert!(content.contains("BSC_KEY_123"));
1438    }
1439
1440    #[test]
1441    fn test_wizard_etherscan_already_configured() {
1442        let tmp = tempfile::tempdir().unwrap();
1443        let config_path = tmp.path().join("config.yaml");
1444        let mut config = Config::default();
1445        config
1446            .chains
1447            .api_keys
1448            .insert("etherscan".to_string(), "existing_key".to_string());
1449
1450        // Decline other chains, keep default format
1451        let input = b"n\n\n";
1452        let mut reader = std::io::Cursor::new(&input[..]);
1453        let mut writer = Vec::new();
1454
1455        run_setup_wizard_impl(&mut reader, &mut writer, &config, &config_path).unwrap();
1456
1457        let output = String::from_utf8(writer).unwrap();
1458        assert!(output.contains("Etherscan API key already configured"));
1459        assert!(output.contains("No changes made"));
1460    }
1461}