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