use crossterm::event::{
DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers,
};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use insmaller_core::{InputResolver, PromptSpec, ResolvedInput};
use std::io::{IsTerminal, Write};
use std::sync::Mutex;
static INTERACTIVE_LOCK: Mutex<()> = Mutex::new(());
pub enum InteractiveLine {
Line(String),
Cancel,
NoTty,
}
pub trait InteractiveIo: Send + Sync {
fn is_tty(&self) -> bool;
fn env(&self, key: &str) -> Option<String>;
fn read_line(&self, message: &str, secret: bool) -> std::io::Result<InteractiveLine>;
}
pub struct RealIo;
impl InteractiveIo for RealIo {
fn is_tty(&self) -> bool {
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}
fn env(&self, key: &str) -> Option<String> {
insmaller_core::env_nonempty(key)
}
fn read_line(&self, message: &str, secret: bool) -> std::io::Result<InteractiveLine> {
if !self.is_tty() {
return Ok(InteractiveLine::NoTty);
}
maybe_block_in_place(|| self.read_line_blocking(message, secret))
}
}
impl RealIo {
fn read_line_blocking(&self, message: &str, secret: bool) -> std::io::Result<InteractiveLine> {
let _guard = INTERACTIVE_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let mut out = std::io::stdout();
write!(out, "{message} ")?;
out.flush()?;
if secret {
read_masked_line()
} else {
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim_end_matches(['\r', '\n']).to_string();
Ok(InteractiveLine::Line(trimmed))
}
}
}
fn maybe_block_in_place<T>(f: impl FnOnce() -> T) -> T {
use tokio::runtime::{Handle, RuntimeFlavor};
match Handle::try_current() {
Ok(h) if h.runtime_flavor() == RuntimeFlavor::MultiThread => {
tokio::task::block_in_place(f)
}
_ => f(),
}
}
struct RawModeGuard;
impl RawModeGuard {
fn enable() -> std::io::Result<Self> {
enable_raw_mode()?;
Ok(Self)
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
}
}
struct BracketedPasteGuard;
impl BracketedPasteGuard {
fn enable() -> std::io::Result<Self> {
crossterm::execute!(std::io::stdout(), EnableBracketedPaste)?;
Ok(Self)
}
}
impl Drop for BracketedPasteGuard {
fn drop(&mut self) {
let _ = crossterm::execute!(std::io::stdout(), DisableBracketedPaste);
}
}
fn read_masked_line() -> std::io::Result<InteractiveLine> {
let _raw = RawModeGuard::enable()?;
let _paste_guard = BracketedPasteGuard::enable().ok();
let mut buf = String::new();
let mut out = std::io::stdout();
loop {
match crossterm::event::read()? {
Event::Key(KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
let ctrl = modifiers.contains(KeyModifiers::CONTROL);
match masked_key(&mut buf, code, ctrl) {
KeyEffect::Submit => {
writeln!(out)?;
out.flush()?;
return Ok(InteractiveLine::Line(buf));
}
KeyEffect::Cancel => {
writeln!(out)?;
out.flush()?;
return Ok(InteractiveLine::Cancel);
}
KeyEffect::Echo(s) => {
write!(out, "{s}")?;
out.flush()?;
}
KeyEffect::Ignore => {}
}
}
Event::Paste(s) => {
let kept = paste_filter(&s);
for _ in kept.chars() {
write!(out, "*")?;
}
buf.push_str(&kept);
out.flush()?;
}
_ => {}
}
}
}
enum KeyEffect {
Echo(&'static str),
Submit,
Cancel,
Ignore,
}
fn masked_key(buf: &mut String, code: KeyCode, ctrl: bool) -> KeyEffect {
match code {
KeyCode::Enter => KeyEffect::Submit,
KeyCode::Esc => KeyEffect::Cancel,
KeyCode::Char(c) if ctrl && matches!(c.to_ascii_lowercase(), 'c' | 'd') => {
KeyEffect::Cancel
}
KeyCode::Char(_) if ctrl => KeyEffect::Ignore,
KeyCode::Backspace => {
if buf.pop().is_some() {
KeyEffect::Echo("\x08 \x08")
} else {
KeyEffect::Ignore
}
}
KeyCode::Char(c) => {
buf.push(c);
KeyEffect::Echo("*")
}
_ => KeyEffect::Ignore,
}
}
fn paste_filter(s: &str) -> String {
s.chars().filter(|&c| c != '\n' && c != '\r').collect()
}
pub struct InteractiveResolver {
io: Box<dyn InteractiveIo>,
fallback: Box<dyn InputResolver>,
}
impl InteractiveResolver {
pub fn new<I, F>(io: I, fallback: F) -> Self
where
I: InteractiveIo + 'static,
F: InputResolver + 'static,
{
Self {
io: Box::new(io),
fallback: Box::new(fallback),
}
}
}
impl InputResolver for InteractiveResolver {
fn resolve(&self, key: &str, spec: &PromptSpec) -> ResolvedInput {
if let Some(v) = self.io.env(&spec.env_key) {
return ResolvedInput::Value(v);
}
if !self.io.is_tty() {
return self.fallback.resolve(key, spec);
}
match self.io.read_line(&spec.message, spec.secret) {
Ok(InteractiveLine::Line(v)) => {
if v.is_empty() {
if spec.required {
ResolvedInput::Fail(format!("input '{}' required", spec.env_key))
} else {
ResolvedInput::Skip
}
} else {
ResolvedInput::Value(v)
}
}
Ok(InteractiveLine::Cancel) => {
if spec.required {
ResolvedInput::Fail(format!("input '{}' cancelled", spec.env_key))
} else {
ResolvedInput::Skip
}
}
Ok(InteractiveLine::NoTty) => self.fallback.resolve(key, spec),
Err(e) => ResolvedInput::Fail(format!("input '{}' read error: {e}", spec.env_key)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use insmaller_core::EnvResolver;
use std::collections::HashMap;
use std::sync::Mutex;
struct FakeIo {
tty: bool,
env: HashMap<String, String>,
answers: Mutex<Vec<InteractiveLine>>,
}
impl FakeIo {
fn new(tty: bool) -> Self {
Self {
tty,
env: HashMap::new(),
answers: Mutex::new(Vec::new()),
}
}
fn with_env(mut self, k: &str, v: &str) -> Self {
self.env.insert(k.into(), v.into());
self
}
fn queue(self, line: InteractiveLine) -> Self {
self.answers.lock().unwrap().insert(0, line);
self
}
}
impl InteractiveIo for FakeIo {
fn is_tty(&self) -> bool {
self.tty
}
fn env(&self, key: &str) -> Option<String> {
self.env.get(key).cloned()
}
fn read_line(&self, _message: &str, _secret: bool) -> std::io::Result<InteractiveLine> {
Ok(self
.answers
.lock()
.unwrap()
.pop()
.unwrap_or(InteractiveLine::NoTty))
}
}
fn spec(env_key: &str, required: bool, secret: bool) -> PromptSpec {
PromptSpec {
env_key: env_key.into(),
message: format!("{env_key}:"),
required,
secret,
}
}
const UNSET: &str = "INSMALLER_INTERACTIVE_TEST_NEVER_SET_XYZ";
#[test]
fn env_wins_even_on_tty_and_skips_prompt() {
let io = FakeIo::new(true).with_env("TOKEN", "abc");
let r = InteractiveResolver::new(io, EnvResolver);
let out = r.resolve("TOKEN", &spec("TOKEN", true, false));
assert_eq!(out, ResolvedInput::Value("abc".into()));
}
#[test]
fn no_tty_delegates_to_fallback() {
let io = FakeIo::new(false);
let r = InteractiveResolver::new(io, EnvResolver);
let out = r.resolve("K", &spec(UNSET, true, false));
assert!(matches!(out, ResolvedInput::Fail(_)));
}
#[test]
fn no_tty_optional_missing_skips_via_fallback() {
let io = FakeIo::new(false);
let r = InteractiveResolver::new(io, EnvResolver);
let out = r.resolve("K", &spec(UNSET, false, false));
assert_eq!(out, ResolvedInput::Skip);
}
#[test]
fn tty_prompt_reads_value() {
let io = FakeIo::new(true).queue(InteractiveLine::Line("typed".into()));
let r = InteractiveResolver::new(io, EnvResolver);
let out = r.resolve("X", &spec("X", true, false));
assert_eq!(out, ResolvedInput::Value("typed".into()));
}
#[test]
fn tty_prompt_empty_required_fails_fast() {
let io = FakeIo::new(true).queue(InteractiveLine::Line(String::new()));
let r = InteractiveResolver::new(io, EnvResolver);
let out = r.resolve("X", &spec("X", true, false));
assert!(matches!(out, ResolvedInput::Fail(_)));
}
#[test]
fn tty_prompt_cancel_required_reports_cancelled() {
let io = FakeIo::new(true).queue(InteractiveLine::Cancel);
let r = InteractiveResolver::new(io, EnvResolver);
let out = r.resolve("X", &spec("X", true, false));
match out {
ResolvedInput::Fail(m) => assert!(m.contains("cancelled")),
o => panic!("expected Fail(cancelled), got {o:?}"),
}
}
#[test]
fn tty_prompt_cancel_optional_skips() {
let io = FakeIo::new(true).queue(InteractiveLine::Cancel);
let r = InteractiveResolver::new(io, EnvResolver);
let out = r.resolve("X", &spec("X", false, false));
assert_eq!(out, ResolvedInput::Skip);
}
#[test]
fn tty_prompt_optional_empty_skips() {
let io = FakeIo::new(true).queue(InteractiveLine::Line(String::new()));
let r = InteractiveResolver::new(io, EnvResolver);
let out = r.resolve("X", &spec("X", false, false));
assert_eq!(out, ResolvedInput::Skip);
}
fn ch(c: char) -> KeyCode {
KeyCode::Char(c)
}
#[test]
fn masked_key_types_and_masks() {
let mut buf = String::new();
for c in "hunter2".chars() {
assert!(matches!(masked_key(&mut buf, ch(c), false), KeyEffect::Echo("*")));
}
assert_eq!(buf, "hunter2");
}
#[test]
fn masked_key_backspace_pops_and_erases_then_noops_on_empty() {
let mut buf = "ab".to_string();
assert!(matches!(
masked_key(&mut buf, KeyCode::Backspace, false),
KeyEffect::Echo("\x08 \x08")
));
assert_eq!(buf, "a");
masked_key(&mut buf, KeyCode::Backspace, false);
assert_eq!(buf, "");
assert!(matches!(
masked_key(&mut buf, KeyCode::Backspace, false),
KeyEffect::Ignore
));
}
#[test]
fn masked_key_ctrl_chords_drop_not_pushed() {
let mut buf = "x".to_string();
for c in ['u', 'w', 'l', 'a'] {
assert!(matches!(masked_key(&mut buf, ch(c), true), KeyEffect::Ignore));
}
assert_eq!(buf, "x", "no ctrl chord may corrupt the secret buffer");
}
#[test]
fn masked_key_ctrl_c_and_d_cancel() {
let mut buf = String::new();
assert!(matches!(masked_key(&mut buf, ch('c'), true), KeyEffect::Cancel));
assert!(matches!(masked_key(&mut buf, ch('d'), true), KeyEffect::Cancel));
assert!(matches!(masked_key(&mut buf, ch('C'), true), KeyEffect::Cancel));
assert!(matches!(masked_key(&mut buf, ch('D'), true), KeyEffect::Cancel));
}
#[test]
fn masked_key_enter_submits_esc_cancels() {
let mut buf = String::new();
assert!(matches!(masked_key(&mut buf, KeyCode::Enter, false), KeyEffect::Submit));
assert!(matches!(masked_key(&mut buf, KeyCode::Esc, false), KeyEffect::Cancel));
}
#[test]
fn paste_filter_drops_only_line_breaks() {
assert_eq!(paste_filter("a\tb\r\nc\nd"), "a\tbcd");
assert_eq!(paste_filter("no-breaks"), "no-breaks");
}
}