//! Global non-interactive mode: --no-input, ATGC_NO_INPUT=1, CI, or a //! stdin that is not a terminal. //! //! atgc's rule is that it never prompts — see the account module for the //! reasoning — but a few commands still assume a human is nearby: `auth //! login` opens a browser and waits for it, `browse` and the `--web` flags //! open pages, and git subprocesses inherit stdio, where a credential //! helper or SSH can stop on a question nothing will answer. In a pipe, a //! CI job or an agent harness, "stop on a question" means "hang until //! killed". This module decides once whether anybody is there, and those //! sites consult it: refuse with instructions instead of waiting, print //! the URL instead of opening it, and tell git to fail rather than ask. //! //! The decision reads only universal signals — the flag, the env var, //! `CI`, and whether stdin is a terminal. Vendor-specific "an agent is //! running" variables are deliberately ignored: agents run both with and //! without terminals, and behavior that varies by which product spawned //! the process is behavior nobody can debug. //! //! Both variables are read through [`crate::env::switch`], which is the one //! reading every boolean atgc takes from the environment. `CI=false` is //! therefore *not* CI, which is the only reading of that spelling anybody //! means; this module used to treat it as being in CI because the variable //! was set at all. use std::io::IsTerminal; use std::sync::OnceLock; static WHY: OnceLock> = OnceLock::new(); static CI: OnceLock = OnceLock::new(); pub fn init(flag: bool) { let ci = crate::env::switch("CI"); let why = decide( flag, crate::env::switch("ATGC_NO_INPUT"), ci, std::io::stdin().is_terminal(), ); let _ = WHY.set(why); let _ = CI.set(ci); if let Some(why) = why { crate::logging::debug::log(format!("non-interactive: {why}")); } } /// The one decision, kept pure so the tests can hold it still. Any single /// signal is enough; the precedence order only chooses which *reason* an /// error message repeats back, so the most explicit signal — the thing the /// user actually did — wins. fn decide(flag: bool, env: bool, ci: bool, stdin_tty: bool) -> Option<&'static str> { if flag { Some("--no-input") } else if env { Some("ATGC_NO_INPUT is set") } else if ci { Some("CI is set") } else if !stdin_tty { Some("stdin is not a terminal") } else { None } } /// Whether this invocation must never wait on a human. pub fn active() -> bool { why().is_some() } /// What made the session non-interactive, for error messages. pub fn why() -> Option<&'static str> { WHY.get().copied().flatten() } /// Whether this is a CI job, on its own rather than folded into [`why`]. /// /// The one signal here that says something [`why`] cannot. The other three /// mean "nobody is at this terminal", which is not the same as "nothing will /// act on what atgc prints": a pipe usually has a program on the far end, and /// `cmd::auth::login` now depends on that — it prints an authorization URL and /// waits five minutes for something to open it. /// /// `CI` is different in kind. A CI job has no desktop, no browser and nobody /// to consent, so an authorization URL printed into one leads nowhere, and the /// only possible ending is the timeout. That makes it worth refusing where the /// others are not, and it is the *only* one worth refusing: `--no-input` and /// `ATGC_NO_INPUT` are how a person at a terminal says "open no browser at /// me", which they are entitled to say while still meaning to open the page /// themselves. /// /// Kept apart rather than read at the call site so that both answers come from /// the same reading of the same variable at the same moment — `CI=false` is /// not CI here for the same reason it is not there. pub fn in_ci() -> bool { CI.get().copied().unwrap_or(false) } /// What became of a URL handed to a browser. /// /// [`Opened`](BrowserOpen::Opened) is the launcher's word rather than the /// user's eyes: it means the platform's opener — `xdg-open` and friends — /// was spawned and exited happily, which is as much as any process can /// know from here. A caller that shows a link for the case where nothing /// appeared should keep showing it on `Opened` too. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BrowserOpen { /// A browser was launched for the URL. Opened, /// A browser was tried and would not start, or this build has no /// browser support compiled in. Failed, /// Nothing was tried: the session is non-interactive. Skipped, } impl BrowserOpen { /// The one spelling of each outcome, for the OAuth log. pub fn label(self) -> &'static str { match self { Self::Opened => "opened", Self::Failed => "failed", Self::Skipped => "skipped", } } } /// Open `url` in a browser, unless nobody is there to see it. Callers that /// print the URL anyway lose nothing by the skip, while opening a browser /// from a CI job or an agent sandbox is at best a stray window on someone /// else's desktop. /// /// The return value is for callers whose next line of output depends on /// what happened — see `cmd::auth::login`, which offers a link only because it /// cannot be sure the browser arrived. pub fn open_in_browser(url: &str) -> BrowserOpen { if let Some(why) = why() { crate::logging::debug::log(format!("not opening a browser ({why}): {url}")); return BrowserOpen::Skipped; } if jacquard::oauth::loopback::try_open_in_browser(url) { crate::logging::debug::log(format!("opened a browser for {url}")); BrowserOpen::Opened } else { crate::logging::debug::log(format!("could not open a browser for {url}")); BrowserOpen::Failed } } #[cfg(test)] mod tests { use super::decide; /// A terminal on stdin and no other signal is the interactive case, /// and the only one: every signal alone is enough to switch modes. #[test] fn any_single_signal_is_enough() { assert_eq!(decide(false, false, false, true), None); assert!(decide(true, false, false, true).is_some()); assert!(decide(false, true, false, true).is_some()); assert!(decide(false, false, true, true).is_some()); assert!(decide(false, false, false, false).is_some()); } /// The reason is repeated back in refusals, so it must name the most /// explicit thing the user did, not whichever signal happened to be /// checked first. `--no-input` in CI should say `--no-input`. #[test] fn the_reason_names_the_most_explicit_signal() { assert_eq!(decide(true, true, true, false), Some("--no-input")); assert_eq!( decide(false, true, true, false), Some("ATGC_NO_INPUT is set") ); assert_eq!(decide(false, false, true, false), Some("CI is set")); assert_eq!( decide(false, false, false, false), Some("stdin is not a terminal") ); } }