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