use std::sync::Mutex;
use std::time::Duration;
use super::{PaneState, confirm_prompt, continuation_kind, probe_reply};
use crate::session_manager::{ManagedError, ManagedTmuxDriver};
const FAST: Duration = Duration::from_millis(1);
#[derive(Clone, Copy, PartialEq, Eq)]
enum Shell {
Executes,
Deaf,
WedgedUntilInterrupt,
WedgedForever,
Unobservable,
ProbeUnreachable,
}
struct ScriptedShell {
behaviour: Shell,
text: Mutex<String>,
interrupts: Mutex<usize>,
sent: Mutex<Vec<String>>,
freed: Mutex<bool>,
}
impl ScriptedShell {
fn new(behaviour: Shell) -> Self {
let text = match behaviour {
Shell::WedgedUntilInterrupt | Shell::WedgedForever => "quote>".to_owned(),
_ => "~/work %".to_owned(),
};
Self {
behaviour,
text: Mutex::new(text),
interrupts: Mutex::new(0),
sent: Mutex::new(Vec::new()),
freed: Mutex::new(false),
}
}
fn wedged_now(&self) -> bool {
match self.behaviour {
Shell::WedgedForever => true,
Shell::WedgedUntilInterrupt => !*self.freed.lock().expect("freed"),
_ => false,
}
}
fn interrupt(&self) {
*self.interrupts.lock().expect("interrupts") += 1;
if self.behaviour == Shell::WedgedUntilInterrupt {
*self.freed.lock().expect("freed") = true;
*self.text.lock().expect("text") = "~/work %".to_owned();
}
}
fn run(&self, line: &str) {
self.sent.lock().expect("sent").push(line.to_owned());
if self.behaviour == Shell::Deaf || self.wedged_now() {
return;
}
if let Some(out) = probe_reply(line) {
let mut t = self.text.lock().expect("text");
t.push('\n');
t.push_str(&out);
}
}
}
impl ManagedTmuxDriver for ScriptedShell {
fn create_session(&self, _n: &str, _w: &str) -> Result<(), ManagedError> {
Ok(())
}
fn kill_session(&self, _n: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_line(&self, _n: &str, text: &str) -> Result<(), ManagedError> {
self.run(text);
Ok(())
}
fn send_line_to_pane(&self, _n: &str, _p: &str, text: &str) -> Result<(), ManagedError> {
self.run(text);
Ok(())
}
fn send_interrupt(&self, _n: &str) -> Result<(), ManagedError> {
self.interrupt();
Ok(())
}
fn send_interrupt_to_pane(&self, _n: &str, _p: &str) -> Result<(), ManagedError> {
self.interrupt();
Ok(())
}
fn capture(&self, _n: &str, _l: usize) -> Result<String, ManagedError> {
if self.behaviour == Shell::Unobservable {
return Err(ManagedError::TmuxUnavailable("no pane".into()));
}
Ok(self.text.lock().expect("text").clone())
}
fn capture_pane(&self, n: &str, _p: &str, l: usize) -> Result<String, ManagedError> {
self.capture(n, l)
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
if matches!(
self.behaviour,
Shell::Unobservable | Shell::ProbeUnreachable
) {
return Err(ManagedError::TmuxUnavailable("no server".into()));
}
Ok(vec!["tmpm-x".to_owned()])
}
}
fn confirm(shell: &ScriptedShell) -> PaneState {
confirm_prompt(shell, "tmpm-x", Some("%1"), 2, 4, FAST)
}
#[test]
fn confirm_prompt_is_ready_when_the_probe_echoes() {
assert_eq!(
confirm(&ScriptedShell::new(Shell::Executes)),
PaneState::Ready
);
}
#[test]
fn confirm_prompt_reports_an_unresponsive_shell() {
assert_eq!(
confirm(&ScriptedShell::new(Shell::Deaf)),
PaneState::Unresponsive
);
}
#[test]
fn confirm_prompt_reports_a_wedged_continuation_prompt() {
assert_eq!(
confirm(&ScriptedShell::new(Shell::WedgedForever)),
PaneState::Continuation("quote>".to_owned())
);
}
#[test]
fn confirm_prompt_interrupts_a_continuation_prompt_before_probing() {
let shell = ScriptedShell::new(Shell::WedgedForever);
let _ = confirm(&shell);
assert!(
*shell.interrupts.lock().expect("interrupts") > 0,
"a visibly wedged pane must be flushed, not probed into"
);
}
#[test]
fn confirm_prompt_recovers_a_pane_that_frees_up_after_the_interrupt() {
let shell = ScriptedShell::new(Shell::WedgedUntilInterrupt);
assert_eq!(confirm(&shell), PaneState::Ready);
}
#[test]
fn confirm_prompt_never_sends_a_closing_quote_or_a_bare_enter() {
let shell = ScriptedShell::new(Shell::WedgedForever);
let _ = confirm(&shell);
for line in shell.sent.lock().expect("sent").iter() {
assert!(
probe_reply(line).is_some(),
"only probe lines may be typed at a wedged pane, got {line:?}"
);
}
}
#[test]
fn confirm_prompt_is_unobservable_when_the_existence_probe_errors() {
let shell = ScriptedShell::new(Shell::ProbeUnreachable);
assert_eq!(
confirm(&shell),
PaneState::Unobservable,
"a probe error must answer 'cannot tell', never a refusal"
);
assert!(
shell.sent.lock().expect("sent").is_empty(),
"an unanswerable existence probe must cost no probe line and no delay"
);
}
#[test]
fn confirm_prompt_is_unobservable_when_the_driver_cannot_capture() {
let shell = ScriptedShell::new(Shell::Unobservable);
let state = confirm(&shell);
assert_eq!(state, PaneState::Unobservable);
assert!(
shell.sent.lock().expect("sent").is_empty(),
"an unobservable pane must cost no probe and no delay"
);
}
#[test]
fn an_unobservable_session_is_not_called_unresponsive() {
let driver = crate::session_manager::FakeNoopTmuxDriver;
let started = std::time::Instant::now();
let state = confirm_prompt(
&driver,
"tmpm-absent",
None,
2,
20,
Duration::from_millis(50),
);
assert_eq!(
state,
PaneState::Unobservable,
"a driver that cannot see the session teaches nothing; refusing the launch on it \
is the false positive this module's own `may_launch` rule forbids"
);
assert!(
started.elapsed() < Duration::from_millis(50),
"and it must cost none of the blocking budget, took {:?}",
started.elapsed()
);
}
#[test]
fn the_handshake_does_not_park_a_tokio_worker() {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.expect("runtime");
rt.block_on(async {
let ran = std::sync::Arc::new(tokio::sync::Notify::new());
let probing = std::sync::Arc::new(tokio::sync::Notify::new());
let other = tokio::spawn({
let ran = std::sync::Arc::clone(&ran);
let probing = std::sync::Arc::clone(&probing);
async move {
probing.notified().await;
ran.notify_one();
}
});
let handshake = tokio::spawn({
let probing = std::sync::Arc::clone(&probing);
async move {
let shell = ScriptedShell::new(Shell::Deaf);
probing.notify_one();
confirm_prompt(
&shell,
"tmpm-x",
Some("%1"),
1,
4,
Duration::from_millis(50),
)
}
});
tokio::time::timeout(Duration::from_secs(5), ran.notified())
.await
.expect(
"a sibling task must make progress while the handshake waits — the \
handshake parked the runtime's only worker",
);
other.await.expect("the sibling task joins");
assert_eq!(
handshake.await.expect("the handshake joins"),
PaneState::Unresponsive,
"the handshake still reaches its own verdict"
);
});
}
#[test]
fn unobservable_panes_are_still_launched_into() {
assert!(PaneState::Unobservable.may_launch());
assert!(PaneState::Ready.may_launch());
assert!(!PaneState::Unresponsive.may_launch());
assert!(!PaneState::Continuation("quote>".to_owned()).may_launch());
}
#[test]
fn a_wedged_pane_names_the_continuation_marker() {
let msg = PaneState::Continuation("dquote>".to_owned())
.refusal()
.expect("a wedged pane is a refusal");
assert!(msg.contains("dquote>"), "{msg}");
assert!(msg.contains("Nothing was typed"), "{msg}");
}
#[test]
fn an_unresponsive_pane_says_nothing_was_reading_the_tty() {
let msg = PaneState::Unresponsive
.refusal()
.expect("an unresponsive pane is a refusal");
assert!(msg.contains("never executed a probe"), "{msg}");
assert!(PaneState::Ready.refusal().is_none());
assert!(PaneState::Unobservable.refusal().is_none());
}
#[test]
fn continuation_kind_recognises_an_open_quote() {
assert_eq!(continuation_kind("some output\nquote> "), Some("quote>"));
}
#[test]
fn continuation_kind_recognises_a_composed_zsh_prompt() {
assert_eq!(continuation_kind("cmdand cursh>"), Some("cursh>"));
}
#[test]
fn continuation_kind_ignores_an_ordinary_prompt() {
assert_eq!(continuation_kind("masa@host ~/work %"), None);
assert_eq!(continuation_kind("some-prompt >"), None);
}
#[test]
fn continuation_kind_ignores_empty_output() {
assert_eq!(continuation_kind(""), None);
assert_eq!(continuation_kind("\n\n \n"), None);
}
#[test]
fn the_probe_line_does_not_contain_its_own_output() {
let shell = ScriptedShell::new(Shell::Executes);
assert_eq!(confirm(&shell), PaneState::Ready);
let sent = shell.sent.lock().expect("sent");
let typed = sent.first().expect("a probe was typed");
let printed = probe_reply(typed).expect("it is a probe line");
assert!(
!typed.contains(&printed),
"the echo of the keystrokes must not satisfy the scrape: {typed:?}"
);
assert!(
typed.len() < crate::core::tmux::MAX_PANE_COMMAND_BYTES,
"the probe must never be truncatable itself"
);
}
#[test]
fn probe_reply_answers_only_a_probe_line() {
assert!(probe_reply("cd /tmp && claude").is_none());
assert!(probe_reply("echo hello").is_none());
assert!(probe_reply("echo tm-rea\"dy\"-abc123").is_some());
}