Skip to main content

scope_cli/cli/
ghola_offer.rs

1//! # Ghola install offer
2//!
3//! Asks the user before installing the Ghola sidecar, and before leaving
4//! a sidecar that scope started running.
5//!
6//! Scope never installs on its own. It asks:
7//! - when a command builds its transport with `ghola.enabled`, no sidecar
8//!   answers and no `ghola` binary is found (once: a "no" is remembered);
9//! - in the `setup` wizard.
10//!
11//! `setup --status` only suggests it. A run that cannot answer never gets
12//! the question: `--yes`, JSON / CSV / Markdown output, a pipe, the web
13//! API and the TUI pane keep the native fallback and print how to install.
14//!
15//! The install itself is `scope::http::ghola_install`: the latest GitHub
16//! release, its SHA-256 checked against `checksums.txt` before anything
17//! is extracted, into `~/.scope-bca/bin`.
18
19use 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
30/// How long the exit question waits before it stops the sidecar.
31const STOP_QUESTION_TIMEOUT: Duration = Duration::from_secs(15);
32
33/// The file that records a "no" to the transport's install question.
34const DECLINED_FILE: &str = "ghola-install-declined";
35
36/// What an install needs.
37pub struct Installer<'a> {
38    /// Where the release comes from.
39    pub source: &'a dyn ReleaseSource,
40    /// This OS and CPU; `None` when Ghola publishes no archive for it.
41    pub target: Option<Target>,
42    /// Where the binary goes.
43    pub dir: PathBuf,
44    /// `go` on PATH, for building Ghola when no archive fits.
45    pub go: Option<PathBuf>,
46    /// Runs `go install` into a folder (`ghola_install::go_install`).
47    pub go_install: &'a dyn Fn(&Path, &Path) -> Result<PathBuf>,
48}
49
50/// Runs `f` with an installer for this machine: GitHub releases, into
51/// `~/.scope-bca/bin`.
52///
53/// # Errors
54///
55/// Returns `Network` when the download client cannot be built.
56pub 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
68/// True when this run may ask the user: a terminal on stdin, stdout and
69/// stderr, outside the TUI and the web server.
70pub 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
78/// True when the run is scripted: `--yes`, `--ai`, or JSON, CSV or
79/// Markdown output (from the flags or the config). Such a run never asks.
80pub 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
97/// The process arguments, for [`scripted`].
98pub fn process_args() -> Vec<String> {
99    std::env::args().skip(1).collect()
100}
101
102/// Asks whether to install Ghola and installs it on a yes. Returns the
103/// installed binary.
104///
105/// An install that fails is reported with the manual steps, and the run
106/// goes on without Ghola.
107///
108/// # Errors
109///
110/// Returns the prompt's read or write error.
111pub 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
159/// With no release archive for this machine: offers `go install` when
160/// `go` is on PATH, else prints the manual steps.
161fn 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
196/// Runs the async install to its end on a thread of its own, so a
197/// synchronous prompt flow (the wizard) can wait for it from inside or
198/// outside a runtime.
199fn 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
224/// The transport's offer: asks once (a "no" is remembered in the state
225/// folder) and installs on a yes. Returns the installed binary.
226pub 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
234/// [`offer_for_transport`] with the "no" recorded in `declined` and the
235/// question asked by `ask`.
236fn 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
272/// Stops the sidecar this run started, or leaves it running when the user
273/// says so. With `ask`, the user has 15 seconds to answer;
274/// no answer stops it. Without `ask` it is stopped.
275///
276/// Call it last: an unanswered question leaves a thread reading stdin.
277pub fn finish_sidecar(out: &Output, ask: bool) {
278    finish_with(out, ask, |question| {
279        ask_timeout(out, question, STOP_QUESTION_TIMEOUT)
280    });
281}
282
283/// [`finish_sidecar`] with the exit question answered by `answer`
284/// (`None`: no answer in time).
285fn 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    /// A source that has no files and records what was fetched.
324    #[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        // Nothing was downloaded: there is no archive to fetch.
404        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    /// An installer with no archive for this machine and `go` on PATH.
506    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    /// Serializes the tests that use the spawned sidecar.
611    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    /// Adopts a process that runs until it is stopped as the spawned
618    /// sidecar, and returns its PID.
619    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}