Skip to main content

scope_cli/cli/
setup.rs

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