use std::ffi::OsString;
pub const ALLOW_NON_TTY_ENV: &str = "NET_DECK_ALLOW_NON_TTY";
#[derive(Debug, PartialEq, Eq)]
pub enum Startup {
Run,
PrintAndExit { text: String, code: i32 },
FailWith { text: String, code: i32 },
}
fn usage() -> String {
format!(
"net-deck {version} — operator cyberdeck for the Net mesh.\n\
\n\
An interactive terminal UI. There are no operational flags: the mesh \
it attaches to comes from configuration, and everything else is \
driven from inside the interface.\n\
\n\
Usage: net-deck [OPTIONS]\n\
\n\
Options:\n\
\x20 -h, --help Print this help and exit\n\
\x20 -V, --version Print the version and exit\n\
\n\
Inside the interface:\n\
\x20 ? Keybindings and per-tab help\n\
\x20 q Quit\n\
\n\
Deck needs an interactive terminal on BOTH stdin and stdout. If \
either is redirected it refuses to start rather than waiting forever \
for keystrokes nobody can send; set {ALLOW_NON_TTY_ENV}=1 to \
override.\n\
\n\
Docs: https://ai2070.net/docs/reference/deck\n",
version = env!("CARGO_PKG_VERSION"),
)
}
#[derive(Debug, Clone, Copy)]
pub struct Streams {
pub stdin_is_tty: bool,
pub stdout_is_tty: bool,
}
impl Streams {
pub fn probe() -> Self {
use std::io::IsTerminal;
Self {
stdin_is_tty: std::io::stdin().is_terminal(),
stdout_is_tty: std::io::stdout().is_terminal(),
}
}
}
pub fn parse<I, S>(args: I, streams: Streams, allow_non_tty: bool) -> Startup
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
if let Some(arg) = args.into_iter().next() {
let arg = arg.into();
let arg = arg.to_string_lossy().into_owned();
match arg.as_str() {
"-h" | "--help" => {
return Startup::PrintAndExit {
text: usage(),
code: 0,
}
}
"-V" | "--version" => {
return Startup::PrintAndExit {
text: format!("net-deck {}\n", env!("CARGO_PKG_VERSION")),
code: 0,
}
}
other => {
return Startup::FailWith {
text: format!(
"net-deck: unrecognized argument `{other}`\n\
\n\
net-deck takes no operational flags. Run \
`net-deck --help` for what it does accept.\n"
),
code: 2,
};
}
}
}
if !allow_non_tty {
let redirected = match (streams.stdin_is_tty, streams.stdout_is_tty) {
(true, true) => None,
(true, false) => Some(("stdout", "it would emit terminal control sequences into a pipe and then wait for keystrokes nobody is there to send")),
(false, true) => Some(("stdin", "the interface would draw correctly and then wait for keystrokes that cannot arrive, because stdin is not a keyboard")),
(false, false) => Some(("stdin and stdout", "it would emit terminal control sequences into a pipe and wait for keystrokes that cannot arrive")),
};
if let Some((stream, consequence)) = redirected {
return Startup::FailWith {
text: format!(
"net-deck: {stream} is not a terminal, refusing to start.\n\
\n\
Deck is an interactive TUI. Started like this, \
{consequence} — an unattended hang, not an error.\n\
\n\
Run it in a terminal, or set {ALLOW_NON_TTY_ENV}=1 if you \
really do mean it (a recording harness, say).\n"
),
code: 1,
};
}
}
Startup::Run
}
#[cfg(test)]
mod tests {
use super::*;
const TTY: Streams = Streams {
stdin_is_tty: true,
stdout_is_tty: true,
};
const STDOUT_PIPED: Streams = Streams {
stdin_is_tty: true,
stdout_is_tty: false,
};
const STDIN_PIPED: Streams = Streams {
stdin_is_tty: false,
stdout_is_tty: true,
};
const DETACHED: Streams = Streams {
stdin_is_tty: false,
stdout_is_tty: false,
};
#[test]
fn help_prints_usage_and_exits_zero() {
for flag in ["--help", "-h"] {
match parse([flag], TTY, false) {
Startup::PrintAndExit { text, code } => {
assert_eq!(code, 0, "{flag} should exit 0");
assert!(text.contains("Usage: net-deck"), "{flag}: {text}");
assert!(text.contains("--version"), "{flag} should list --version");
}
other => panic!("{flag} produced {other:?}"),
}
}
}
#[test]
fn version_prints_the_crate_version_and_exits_zero() {
for flag in ["--version", "-V"] {
match parse([flag], TTY, false) {
Startup::PrintAndExit { text, code } => {
assert_eq!(code, 0);
assert!(
text.contains(env!("CARGO_PKG_VERSION")),
"{flag} printed {text:?}, which does not contain {}",
env!("CARGO_PKG_VERSION"),
);
}
other => panic!("{flag} produced {other:?}"),
}
}
}
#[test]
fn an_unknown_flag_fails_with_exit_two() {
match parse(["--bogus"], TTY, false) {
Startup::FailWith { text, code } => {
assert_eq!(code, 2, "usage errors exit 2");
assert!(text.contains("--bogus"), "the message must name it: {text}");
assert!(text.contains("--help"), "and point somewhere: {text}");
}
other => panic!("--bogus produced {other:?}"),
}
}
#[test]
fn help_still_works_when_the_streams_are_redirected() {
for streams in [STDOUT_PIPED, STDIN_PIPED, DETACHED] {
assert!(
matches!(
parse(["--help"], streams, false),
Startup::PrintAndExit { code: 0, .. }
),
"--help must answer regardless of the streams: {streams:?}"
);
}
}
#[test]
fn no_args_on_a_terminal_starts_the_interface() {
assert_eq!(parse(Vec::<String>::new(), TTY, false), Startup::Run);
}
#[test]
fn either_stream_redirected_refuses_instead_of_hanging() {
for (streams, expected) in [
(STDOUT_PIPED, "stdout"),
(STDIN_PIPED, "stdin"),
(DETACHED, "stdin and stdout"),
] {
match parse(Vec::<String>::new(), streams, false) {
Startup::FailWith { text, code } => {
assert_eq!(code, 1, "an environment problem, not a usage error");
assert!(
text.contains(&format!("{expected} is not a terminal")),
"the refusal must name which stream is redirected; \
expected {expected:?} for {streams:?}:\n{text}"
);
assert!(
text.contains(ALLOW_NON_TTY_ENV),
"the refusal must name its override: {text}"
);
}
other => panic!("{streams:?} produced {other:?}"),
}
}
}
#[test]
fn the_override_lets_a_non_tty_caller_through() {
for streams in [STDOUT_PIPED, STDIN_PIPED, DETACHED] {
assert_eq!(
parse(Vec::<String>::new(), streams, true),
Startup::Run,
"the override must cover {streams:?} too"
);
}
}
#[test]
fn an_unknown_flag_is_not_rescued_by_a_later_valid_one() {
match parse(["--bogus", "--help"], TTY, false) {
Startup::FailWith { code, .. } => assert_eq!(code, 2),
other => panic!("produced {other:?}"),
}
}
}