1use crate::cli::output::Output;
20use crate::cli::prompt::{ask, ask_timeout};
21use crate::note;
22use scope::config::{Config, OutputFormat};
23use scope::error::{Result, ScopeError};
24use scope::http::ghola;
25use scope::http::ghola_install::{self, GithubReleases, Outcome, ReleaseSource, Target};
26use std::io::{BufRead, IsTerminal};
27use std::path::{Path, PathBuf};
28use std::time::Duration;
29
30const STOP_QUESTION_TIMEOUT: Duration = Duration::from_secs(15);
32
33const DECLINED_FILE: &str = "ghola-install-declined";
35
36pub struct Installer<'a> {
38 pub source: &'a dyn ReleaseSource,
40 pub target: Option<Target>,
42 pub dir: PathBuf,
44 pub go: Option<PathBuf>,
46 pub go_install: &'a dyn Fn(&Path, &Path) -> Result<PathBuf>,
48}
49
50pub fn with_local_installer<T>(f: impl FnOnce(&Installer<'_>) -> T) -> Result<T> {
57 let source = GithubReleases::new()?;
58 let installer = Installer {
59 source: &source,
60 target: Target::current(),
61 dir: ghola_install::install_dir(),
62 go: ghola_install::find_go(),
63 go_install: &ghola_install::go_install,
64 };
65 Ok(f(&installer))
66}
67
68pub fn may_ask(out: &Output) -> bool {
71 out.prompts_allowed()
72 && !out.is_tui()
73 && std::io::stdin().is_terminal()
74 && std::io::stdout().is_terminal()
75 && std::io::stderr().is_terminal()
76}
77
78pub fn scripted(args: &[String], config: &Config) -> bool {
81 if config.output.format != OutputFormat::Table {
82 return true;
83 }
84 const MACHINE: &[&str] = &["json", "csv", "markdown"];
85 args.iter().enumerate().any(|(i, a)| {
86 let next = args.get(i + 1).map(String::as_str);
87 match a.as_str() {
88 "--yes" | "--json" | "--ai" => true,
89 "--format" | "-f" => next.is_some_and(|v| MACHINE.contains(&v)),
90 _ => a
91 .strip_prefix("--format=")
92 .is_some_and(|v| MACHINE.contains(&v)),
93 }
94 })
95}
96
97pub fn process_args() -> Vec<String> {
99 std::env::args().skip(1).collect()
100}
101
102pub fn offer_install(
112 reader: &mut impl BufRead,
113 out: &Output,
114 installer: &Installer<'_>,
115) -> std::io::Result<Option<PathBuf>> {
116 let answer = ask(
117 reader,
118 out,
119 &format!(
120 "Download ghola from its GitHub releases (robot-accomplice/ghola) into {}? [y/N]: ",
121 installer.dir.display()
122 ),
123 )?;
124 if !is_yes(&answer) {
125 return Ok(None);
126 }
127 let outcome = match installer.target {
128 Some(target) => run_install(installer.source, target, &installer.dir),
129 None => Ok(Outcome::NoAsset {
130 version: "latest".into(),
131 asset: format!(
132 "an archive for {}/{}",
133 std::env::consts::OS,
134 std::env::consts::ARCH
135 ),
136 }),
137 };
138 match outcome {
139 Ok(Outcome::Installed { path, version }) => {
140 note!(
141 out,
142 "✓ Installed ghola v{version} at {} (SHA-256 matched the release's checksums.txt)",
143 path.display()
144 );
145 Ok(Some(path))
146 }
147 Ok(Outcome::NoAsset { version, asset }) => {
148 note!(out, "The ghola {version} release has no {asset}.");
149 offer_go_install(reader, out, installer)
150 }
151 Err(e) => {
152 note!(out, "⚠ Could not install ghola: {e}");
153 print_manual_steps(out);
154 Ok(None)
155 }
156 }
157}
158
159fn offer_go_install(
162 reader: &mut impl BufRead,
163 out: &Output,
164 installer: &Installer<'_>,
165) -> std::io::Result<Option<PathBuf>> {
166 let Some(go) = &installer.go else {
167 print_manual_steps(out);
168 return Ok(None);
169 };
170 let answer = ask(
171 reader,
172 out,
173 &format!(
174 "Build it with `{}` into {}? [y/N]: ",
175 ghola_install::GO_INSTALL,
176 installer.dir.display()
177 ),
178 )?;
179 if !is_yes(&answer) {
180 print_manual_steps(out);
181 return Ok(None);
182 }
183 match (installer.go_install)(go, &installer.dir) {
184 Ok(path) => {
185 note!(out, "✓ Built ghola at {}", path.display());
186 Ok(Some(path))
187 }
188 Err(e) => {
189 note!(out, "⚠ Could not build ghola: {e}");
190 print_manual_steps(out);
191 Ok(None)
192 }
193 }
194}
195
196fn run_install(source: &dyn ReleaseSource, target: Target, dir: &Path) -> Result<Outcome> {
200 std::thread::scope(|s| {
201 s.spawn(|| {
202 tokio::runtime::Builder::new_current_thread()
203 .enable_all()
204 .build()
205 .map_err(ScopeError::IoError)?
206 .block_on(ghola_install::install_latest(source, target, dir))
207 })
208 .join()
209 .unwrap_or_else(|_| Err(ScopeError::Other("the ghola install panicked".into())))
210 })
211}
212
213fn print_manual_steps(out: &Output) {
214 note!(out, "To install ghola yourself:");
215 for step in ghola_install::manual_steps() {
216 note!(out, "- {step}");
217 }
218}
219
220fn is_yes(answer: &str) -> bool {
221 matches!(answer.trim().to_lowercase().as_str(), "y" | "yes")
222}
223
224pub fn offer_for_transport(out: &Output) -> Option<PathBuf> {
227 offer_once(out, &scope::paths::state_dir().join(DECLINED_FILE), || {
228 with_local_installer(|installer| {
229 offer_install(&mut crate::cli::prompt::stdin(), out, installer)
230 })
231 })
232}
233
234fn offer_once(
237 out: &Output,
238 declined: &Path,
239 ask: impl FnOnce() -> Result<std::io::Result<Option<PathBuf>>>,
240) -> Option<PathBuf> {
241 if declined.exists() {
242 return None;
243 }
244 note!(
245 out,
246 "Ghola is enabled (ghola.enabled) but ghola is not installed."
247 );
248 match ask() {
249 Ok(Ok(Some(path))) => Some(path),
250 Ok(Ok(None)) => {
251 let _ = declined
252 .parent()
253 .map_or(Ok(()), std::fs::create_dir_all)
254 .and_then(|()| std::fs::write(declined, b""));
255 note!(
256 out,
257 "Not asking again. Run `scope setup` to install ghola later."
258 );
259 None
260 }
261 Ok(Err(e)) => {
262 tracing::debug!("ghola install question failed: {e}");
263 None
264 }
265 Err(e) => {
266 note!(out, "⚠ Could not prepare the ghola download: {e}");
267 None
268 }
269 }
270}
271
272pub fn finish_sidecar(out: &Output, ask: bool) {
278 finish_with(out, ask, |question| {
279 ask_timeout(out, question, STOP_QUESTION_TIMEOUT)
280 });
281}
282
283fn finish_with(
286 out: &Output,
287 ask: bool,
288 answer: impl FnOnce(&str) -> std::io::Result<Option<String>>,
289) {
290 let Some(pid) = ghola::spawned_sidecar() else {
291 return;
292 };
293 let keep = ask && {
294 let question = format!(
295 "Stop the Ghola sidecar that scope started (pid {pid})? [Y/n] (stops in {}s): ",
296 STOP_QUESTION_TIMEOUT.as_secs()
297 );
298 match answer(&question) {
299 Ok(Some(answer)) => matches!(answer.trim().to_lowercase().as_str(), "n" | "no"),
300 Ok(None) => {
301 note!(out);
302 false
303 }
304 Err(_) => false,
305 }
306 };
307 if keep {
308 note!(
309 out,
310 "The Ghola sidecar keeps running (pid {pid}); later runs use it."
311 );
312 } else if ghola::stop_spawned_sidecar().is_some() {
313 note!(out, "Stopped the Ghola sidecar (pid {pid}).");
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use async_trait::async_trait;
321 use std::sync::Mutex;
322
323 #[derive(Default)]
325 struct EmptySource {
326 fetched: Mutex<Vec<String>>,
327 }
328
329 #[async_trait]
330 impl ReleaseSource for EmptySource {
331 async fn fetch(&self, url: &str) -> Result<Vec<u8>> {
332 self.fetched.lock().unwrap().push(url.to_string());
333 Err(ScopeError::Network(format!("HTTP 404 for {url}")))
334 }
335 }
336
337 fn no_go(_: &Path, _: &Path) -> Result<PathBuf> {
338 panic!("ran go install")
339 }
340
341 fn installer<'a>(source: &'a EmptySource, dir: &Path) -> Installer<'a> {
342 Installer {
343 source,
344 target: Target::from_rust("linux", "x86_64"),
345 dir: dir.to_path_buf(),
346 go: None,
347 go_install: &no_go,
348 }
349 }
350
351 #[test]
352 fn test_no_answer_downloads_nothing() {
353 let dir = tempfile::tempdir().unwrap();
354 let source = EmptySource::default();
355 let (out, cap) = Output::capture();
356 for answer in ["", "n\n", "no\n"] {
357 let mut reader = answer.as_bytes();
358 let got = offer_install(&mut reader, &out, &installer(&source, dir.path())).unwrap();
359 assert_eq!(got, None);
360 }
361 assert!(source.fetched.lock().unwrap().is_empty());
362 assert!(cap.err().contains("[y/N]"));
363 }
364
365 #[test]
366 fn test_a_failed_download_prints_the_manual_steps() {
367 let dir = tempfile::tempdir().unwrap();
368 let source = EmptySource::default();
369 let (out, cap) = Output::capture();
370 let got =
371 offer_install(&mut "y\n".as_bytes(), &out, &installer(&source, dir.path())).unwrap();
372 assert_eq!(got, None);
373 assert_eq!(
374 source.fetched.lock().unwrap().as_slice(),
375 [ghola_install::LATEST_RELEASE_URL]
376 );
377 let err = cap.err();
378 assert!(err.contains("Could not install ghola"), "{err}");
379 assert!(err.contains("/cmd/ghola@latest"), "{err}");
380 assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
381 }
382
383 #[test]
384 fn test_no_archive_for_the_target_offers_go_install() {
385 let dir = tempfile::tempdir().unwrap();
386 let source = EmptySource::default();
387 let built = dir.path().join("ghola");
388 let go_install = |go: &Path, into: &Path| -> Result<PathBuf> {
389 assert_eq!(go, Path::new("/usr/bin/go"));
390 Ok(into.join("ghola"))
391 };
392 let installer = Installer {
393 source: &source,
394 target: None,
395 dir: dir.path().to_path_buf(),
396 go: Some(PathBuf::from("/usr/bin/go")),
397 go_install: &go_install,
398 };
399 let (out, cap) = Output::capture();
400 let got = offer_install(&mut "y\ny\n".as_bytes(), &out, &installer).unwrap();
401 assert_eq!(got, Some(built));
402 assert!(cap.err().contains("go install"), "{}", cap.err());
403 assert!(source.fetched.lock().unwrap().is_empty());
405 }
406
407 #[test]
408 fn test_no_archive_and_no_go_prints_the_steps() {
409 let dir = tempfile::tempdir().unwrap();
410 let source = EmptySource::default();
411 let mut installer = installer(&source, dir.path());
412 installer.target = None;
413 let (out, cap) = Output::capture();
414 let got = offer_install(&mut "y\n".as_bytes(), &out, &installer).unwrap();
415 assert_eq!(got, None);
416 assert!(cap.err().contains("To install ghola yourself"));
417 }
418
419 fn args(a: &[&str]) -> Vec<String> {
420 a.iter().map(|s| s.to_string()).collect()
421 }
422
423 #[test]
424 fn test_scripted_runs_are_recognised() {
425 let table = Config::default();
426 assert!(!scripted(&args(&["crawl", "USDC"]), &table));
427 assert!(scripted(&args(&["crawl", "USDC", "--yes"]), &table));
428 assert!(scripted(
429 &args(&["address", "0x1", "--format", "json"]),
430 &table
431 ));
432 assert!(scripted(&args(&["address", "0x1", "-f", "csv"]), &table));
433 assert!(scripted(&args(&["tx", "0x1", "--format=json"]), &table));
434 assert!(scripted(&args(&["contract", "0x1", "--json"]), &table));
435 assert!(!scripted(
436 &args(&["address", "0x1", "--format", "table"]),
437 &table
438 ));
439 let mut json = Config::default();
440 json.output.format = OutputFormat::Json;
441 assert!(scripted(&args(&["crawl", "USDC"]), &json));
442 }
443
444 #[test]
445 fn test_captured_output_never_asks() {
446 let (out, _) = Output::capture();
447 assert!(!may_ask(&out));
448 }
449
450 #[test]
451 fn test_finish_without_a_spawned_sidecar_says_nothing() {
452 let _lock = sidecar_lock();
453 let (out, cap) = Output::capture();
454 finish_sidecar(&out, true);
455 assert!(cap.err().is_empty());
456 }
457
458 #[test]
459 fn test_the_local_installer_uses_this_machine() {
460 let (dir, target, go) =
461 with_local_installer(|i| (i.dir.clone(), i.target, i.go.clone())).unwrap();
462 assert_eq!(dir, ghola_install::install_dir());
463 assert_eq!(target, Target::current());
464 assert_eq!(go, ghola_install::find_go());
465 }
466
467 #[test]
468 fn test_may_ask_needs_prompts_and_a_terminal() {
469 let out = Output::from_writers(std::io::sink(), std::io::sink());
470 let terminal = std::io::stdin().is_terminal()
471 && std::io::stdout().is_terminal()
472 && std::io::stderr().is_terminal();
473 assert_eq!(may_ask(&out), terminal);
474 assert!(!may_ask(&out.without_prompts()));
475 }
476
477 #[test]
478 fn test_process_args_leave_out_the_program() {
479 assert_eq!(process_args().len(), std::env::args().count() - 1);
480 }
481
482 #[test]
483 fn test_a_yes_installs_the_checked_release() {
484 use ghola_install::test_support::{BINARY, release, tar_gz};
485 let dir = tempfile::tempdir().unwrap();
486 let target = Target::from_rust("linux", "x86_64").unwrap();
487 let source = release(target, tar_gz(&[("ghola", BINARY)]), None);
488 let installer = Installer {
489 source: &source,
490 target: Some(target),
491 dir: dir.path().to_path_buf(),
492 go: None,
493 go_install: &no_go,
494 };
495 let (out, cap) = Output::capture();
496 let got = offer_install(&mut "yes\n".as_bytes(), &out, &installer).unwrap();
497 let path = dir.path().join("ghola");
498 assert_eq!(got, Some(path.clone()));
499 assert_eq!(std::fs::read(&path).unwrap(), BINARY);
500 let err = cap.err();
501 assert!(err.contains("Installed ghola v0.6.2"), "{err}");
502 assert!(err.contains("SHA-256 matched"), "{err}");
503 }
504
505 fn go_only<'a>(
507 source: &'a EmptySource,
508 dir: &Path,
509 go_install: &'a dyn Fn(&Path, &Path) -> Result<PathBuf>,
510 ) -> Installer<'a> {
511 Installer {
512 source,
513 target: None,
514 dir: dir.to_path_buf(),
515 go: Some(PathBuf::from("/usr/bin/go")),
516 go_install,
517 }
518 }
519
520 #[test]
521 fn test_a_no_to_go_install_builds_nothing() {
522 let dir = tempfile::tempdir().unwrap();
523 let source = EmptySource::default();
524 let installer = go_only(&source, dir.path(), &no_go);
525 let (out, cap) = Output::capture();
526 let got = offer_install(&mut "y\nn\n".as_bytes(), &out, &installer).unwrap();
527 assert_eq!(got, None);
528 assert!(cap.err().contains("To install ghola yourself"));
529 }
530
531 #[test]
532 fn test_a_failed_go_install_prints_the_steps() {
533 let dir = tempfile::tempdir().unwrap();
534 let source = EmptySource::default();
535 let fails = |_: &Path, _: &Path| -> Result<PathBuf> {
536 Err(ScopeError::Other("go: module not found".into()))
537 };
538 let installer = go_only(&source, dir.path(), &fails);
539 let (out, cap) = Output::capture();
540 let got = offer_install(&mut "y\ny\n".as_bytes(), &out, &installer).unwrap();
541 assert_eq!(got, None);
542 let err = cap.err();
543 assert!(err.contains("Could not build ghola: "), "{err}");
544 assert!(err.contains("go: module not found"), "{err}");
545 assert!(err.contains("To install ghola yourself"), "{err}");
546 }
547
548 #[test]
549 fn test_the_transport_offer_respects_a_recorded_no() {
550 let dir = tempfile::tempdir().unwrap();
551 let declined = dir.path().join(DECLINED_FILE);
552 std::fs::write(&declined, b"").unwrap();
553 let (out, cap) = Output::capture();
554 let got = offer_once(&out, &declined, || panic!("asked again"));
555 assert_eq!(got, None);
556 assert!(cap.err().is_empty());
557 }
558
559 #[test]
560 fn test_a_no_to_the_transport_offer_is_remembered() {
561 let dir = tempfile::tempdir().unwrap();
562 let declined = dir.path().join("state").join(DECLINED_FILE);
563 let (out, cap) = Output::capture();
564 assert_eq!(offer_once(&out, &declined, || Ok(Ok(None))), None);
565 assert!(declined.exists());
566 let err = cap.err();
567 assert!(err.contains("ghola is not installed"), "{err}");
568 assert!(err.contains("Not asking again"), "{err}");
569 }
570
571 #[test]
572 fn test_a_yes_to_the_transport_offer_returns_the_binary() {
573 let dir = tempfile::tempdir().unwrap();
574 let declined = dir.path().join(DECLINED_FILE);
575 let binary = dir.path().join("ghola");
576 let (out, _) = Output::capture();
577 let got = offer_once(&out, &declined, || Ok(Ok(Some(binary.clone()))));
578 assert_eq!(got, Some(binary));
579 assert!(!declined.exists());
580 }
581
582 #[test]
583 fn test_a_failed_transport_offer_is_not_taken_as_a_no() {
584 let dir = tempfile::tempdir().unwrap();
585 let declined = dir.path().join(DECLINED_FILE);
586 let (out, cap) = Output::capture();
587 let read_failed = || Ok(Err(std::io::Error::other("stdin closed")));
588 assert_eq!(offer_once(&out, &declined, read_failed), None);
589 let no_client = || Err(ScopeError::Network("no TLS".into()));
590 assert_eq!(offer_once(&out, &declined, no_client), None);
591 assert!(!declined.exists());
592 let err = cap.err();
593 assert!(
594 err.contains("Could not prepare the ghola download: "),
595 "{err}"
596 );
597 }
598
599 #[test]
600 fn test_the_transport_offer_reads_the_state_folder() {
601 let declined = scope::paths::state_dir().join(DECLINED_FILE);
602 std::fs::create_dir_all(scope::paths::state_dir()).unwrap();
603 std::fs::write(&declined, b"").unwrap();
604 let (out, cap) = Output::capture();
605 assert_eq!(offer_for_transport(&out), None);
606 assert!(cap.err().is_empty());
607 std::fs::remove_file(&declined).unwrap();
608 }
609
610 static SIDECAR: Mutex<()> = Mutex::new(());
612
613 fn sidecar_lock() -> std::sync::MutexGuard<'static, ()> {
614 SIDECAR.lock().unwrap_or_else(|e| e.into_inner())
615 }
616
617 fn adopt_running_sidecar() -> u32 {
620 let mut command = if cfg!(windows) {
621 let mut c = std::process::Command::new("ping");
622 c.args(["-n", "60", "127.0.0.1"]);
623 c
624 } else {
625 let mut c = std::process::Command::new("sleep");
626 c.arg("60");
627 c
628 };
629 let child = command
630 .stdin(std::process::Stdio::null())
631 .stdout(std::process::Stdio::null())
632 .stderr(std::process::Stdio::null())
633 .spawn()
634 .unwrap();
635 let pid = child.id();
636 ghola::adopt_sidecar(child);
637 pid
638 }
639
640 #[test]
641 fn test_finish_without_asking_stops_the_sidecar() {
642 let _lock = sidecar_lock();
643 let pid = adopt_running_sidecar();
644 let (out, cap) = Output::capture();
645 finish_sidecar(&out, false);
646 assert_eq!(ghola::spawned_sidecar(), None);
647 assert!(
648 cap.err()
649 .contains(&format!("Stopped the Ghola sidecar (pid {pid})")),
650 "{}",
651 cap.err()
652 );
653 }
654
655 #[test]
656 fn test_a_no_at_exit_keeps_the_sidecar() {
657 let _lock = sidecar_lock();
658 let pid = adopt_running_sidecar();
659 let (out, cap) = Output::capture();
660 let mut asked = String::new();
661 finish_with(&out, true, |question| {
662 asked = question.to_string();
663 Ok(Some(" No\n".into()))
664 });
665 assert!(asked.contains(&format!("(pid {pid})")), "{asked}");
666 assert!(asked.contains("stops in 15s"), "{asked}");
667 assert_eq!(ghola::spawned_sidecar(), Some(pid));
668 assert!(cap.err().contains("keeps running"), "{}", cap.err());
669 assert_eq!(ghola::stop_spawned_sidecar(), Some(pid));
670 }
671
672 #[test]
673 fn test_any_other_exit_answer_stops_the_sidecar() {
674 let _lock = sidecar_lock();
675 let answers: [fn() -> std::io::Result<Option<String>>; 4] = [
676 || Ok(Some("\n".into())),
677 || Ok(Some("y\n".into())),
678 || Ok(None),
679 || Err(std::io::Error::other("stdin closed")),
680 ];
681 for answer in answers {
682 let pid = adopt_running_sidecar();
683 let (out, cap) = Output::capture();
684 finish_with(&out, true, |_| answer());
685 assert_eq!(ghola::spawned_sidecar(), None);
686 assert!(
687 cap.err()
688 .contains(&format!("Stopped the Ghola sidecar (pid {pid})")),
689 "{}",
690 cap.err()
691 );
692 }
693 }
694}