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 scope::http::ghola_install;
27use std::io::BufRead;
28use std::path::{Path, PathBuf};
29
30#[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 #[arg(long, short)]
40 pub status: bool,
41
42 #[arg(long, short, value_name = "PROVIDER")]
44 pub key: Option<String>,
45
46 #[arg(long)]
48 pub reset: bool,
49}
50
51#[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
61pub 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 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_setup_wizard(config, out).await
83}
84
85fn 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 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 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 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 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
221fn 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
264fn 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
316fn 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
324fn 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
334fn 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
357async 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
373fn 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 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
439struct ApiKeyInfo {
441 url: &'static str,
442 chain: &'static str,
443 features: &'static str,
444 signup_steps: &'static str,
445}
446
447fn 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#[cfg(test)]
497fn get_api_key_url(key_name: &str) -> &'static str {
498 get_api_key_info(key_name).url
499}
500
501async 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
531type InstallOffer = dyn Fn(&mut dyn BufRead, &Output) -> std::io::Result<Option<PathBuf>>;
533
534struct GholaStep<'a> {
536 installed: Option<PathBuf>,
537 install: &'a InstallOffer,
538}
539
540struct 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
558fn 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 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 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 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 note!(out);
684 note!(out, "Step 2: Preferences");
685 note!(out, "{}", "=".repeat(60));
686 note!(out);
687
688 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 _ => {} }
706
707 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 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
774fn 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
794fn 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
801fn save_config_to_path(config: &Config, config_path: &Path) -> Result<()> {
803 if let Some(parent) = config_path.parent() {
805 std::fs::create_dir_all(parent)?;
806 }
807
808 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#[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 #[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 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 #[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 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 #[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 #[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 #[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 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 #[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()); 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 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 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 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 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 #[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 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()); 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 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()); }
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 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()); }
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 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 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 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 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 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 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 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}