1use 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#[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 #[arg(long, short)]
39 pub status: bool,
40
41 #[arg(long, short, value_name = "PROVIDER")]
43 pub key: Option<String>,
44
45 #[arg(long)]
47 pub reset: bool,
48}
49
50#[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
60pub 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 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_setup_wizard(config, out).await
82}
83
84fn 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 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 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 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 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
230fn 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
241fn 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
293fn 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
301fn 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
311fn 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
334async 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
350fn 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 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
416struct ApiKeyInfo {
418 url: &'static str,
419 chain: &'static str,
420 features: &'static str,
421 signup_steps: &'static str,
422}
423
424fn 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#[cfg(test)]
474fn get_api_key_url(key_name: &str) -> &'static str {
475 get_api_key_info(key_name).url
476}
477
478async 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
488fn 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 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 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 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 note!(out);
613 note!(out, "Step 2: Preferences");
614 note!(out, "{}", "=".repeat(60));
615 note!(out);
616
617 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 _ => {} }
635
636 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
673fn 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
693fn 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
700fn save_config_to_path(config: &Config, config_path: &Path) -> Result<()> {
702 if let Some(parent) = config_path.parent() {
704 std::fs::create_dir_all(parent)?;
705 }
706
707 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#[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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[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()); 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 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 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 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 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 #[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 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()); 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 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()); }
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 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()); }
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 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 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 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 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 let _ = which_ghola();
1531 }
1532
1533 #[test]
1534 fn test_show_status_ghola_disabled() {
1535 let config = Config::default();
1536 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}