use std::collections::VecDeque;
use std::io::{BufReader, IsTerminal};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use tokio::sync::{Notify, mpsc};
use zeph_common::path_guard::{PathRejection, classify_relative_path};
use zeph_core::channel::{
Attachment, AttachmentKind, Channel, ChannelError, ChannelMessage, ElicitationField,
ElicitationFieldType, ElicitationRequest, ElicitationResponse,
};
use crate::line_editor::{self, ReadLineResult};
#[derive(Debug)]
struct StdinCoordination {
elicit_active: AtomicBool,
resume: Notify,
ack: Notify,
parked_generation: AtomicU64,
}
impl StdinCoordination {
fn new() -> Self {
Self {
elicit_active: AtomicBool::new(false),
resume: Notify::new(),
ack: Notify::new(),
parked_generation: AtomicU64::new(0),
}
}
}
const ACK_HANDSHAKE_TIMEOUT: Duration = Duration::from_millis(200);
struct ElicitGuard<'a> {
coord: &'a StdinCoordination,
}
impl<'a> ElicitGuard<'a> {
async fn acquire(coord: &'a StdinCoordination) -> Self {
let start_generation = coord.parked_generation.load(Ordering::Acquire);
coord.elicit_active.store(true, Ordering::Release);
let guard = Self { coord };
let deadline = tokio::time::Instant::now() + ACK_HANDSHAKE_TIMEOUT;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
tracing::debug!(
"ack handshake timed out waiting for the background stdin reader to park; \
proceeding without it (reader may not be running)"
);
break;
}
if tokio::time::timeout(remaining, coord.ack.notified())
.await
.is_err()
{
tracing::debug!(
"ack handshake timed out waiting for the background stdin reader to park; \
proceeding without it (reader may not be running)"
);
break;
}
if coord.parked_generation.load(Ordering::Acquire) > start_generation {
break;
}
}
guard
}
}
impl Drop for ElicitGuard<'_> {
fn drop(&mut self) {
self.coord.elicit_active.store(false, Ordering::Release);
self.coord.resume.notify_one();
}
}
const STDIN_CHANNEL_CAPACITY: usize = 32;
type PersistFn = Box<dyn Fn(&str) + Send>;
struct InputHistory {
entries: VecDeque<String>,
persist_fn: PersistFn,
max_len: usize,
}
impl InputHistory {
fn new(entries: Vec<String>, persist_fn: PersistFn) -> Self {
Self {
entries: VecDeque::from(entries),
persist_fn,
max_len: 1000,
}
}
fn entries(&self) -> &VecDeque<String> {
&self.entries
}
fn add(&mut self, line: &str) {
if line.is_empty() {
return;
}
if self.entries.back().is_some_and(|last| last == line) {
return;
}
if self.entries.len() == self.max_len {
self.entries.pop_front();
}
self.entries.push_back(line.to_owned());
(self.persist_fn)(line);
}
}
impl std::fmt::Debug for InputHistory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InputHistory")
.field("entries_len", &self.entries.len())
.finish_non_exhaustive()
}
}
fn image_path_rejection_message(rejection: PathRejection) -> Option<&'static str> {
match rejection {
PathRejection::Allowed => None,
PathRejection::Absolute => Some(
"Zeph: Invalid image path: absolute paths are not supported, use a path \
relative to the working directory",
),
PathRejection::Traversal => {
Some("Zeph: Invalid image path: path traversal ('..') is not allowed")
}
}
}
async fn process_line(
line: String,
is_tty: bool,
history: &mut Option<InputHistory>,
pending_attachments: &mut Vec<Attachment>,
) -> Result<Option<ChannelMessage>, ()> {
let trimmed = line.trim();
match trimmed {
"exit" | "quit" | "/exit" | "/quit" => return Err(()),
"" => {
if is_tty {
return Err(());
}
return Ok(None);
}
_ => {}
}
if let Some(h) = history {
h.add(trimmed);
}
if let Some(path) = trimmed.strip_prefix("/image").map(str::trim) {
if path.is_empty() {
println!("Zeph: Usage: /image <path>");
return Ok(None);
}
let path_owned = path.to_owned();
if let Some(msg) = image_path_rejection_message(classify_relative_path(&path_owned)) {
println!("{msg}");
return Ok(None);
}
match tokio::fs::read(&path_owned).await {
Err(e) => {
println!("Zeph: Cannot read image {path_owned}: {e}");
}
Ok(data) => {
let filename = std::path::Path::new(&path_owned)
.file_name()
.and_then(|n| n.to_str())
.map(str::to_owned);
let size = data.len();
pending_attachments.push(Attachment {
kind: AttachmentKind::Image,
data,
filename,
});
println!("Zeph: Image attached: {path_owned} ({size} bytes). Send your message.");
}
}
return Ok(None);
}
let attachments = std::mem::take(pending_attachments);
Ok(Some(ChannelMessage {
text: trimmed.to_string(),
attachments,
is_guest_context: false,
is_from_bot: false,
owner_key: None,
}))
}
async fn run_tty_reader(
mut history: Option<InputHistory>,
tx: mpsc::Sender<ChannelMessage>,
coord: Arc<StdinCoordination>,
) {
let mut pending_attachments: Vec<Attachment> = Vec::new();
loop {
while coord.elicit_active.load(Ordering::Acquire) {
coord.parked_generation.fetch_add(1, Ordering::Release);
coord.ack.notify_one();
coord.resume.notified().await;
}
let entries: Vec<String> = history
.as_ref()
.map(|h| h.entries().iter().cloned().collect())
.unwrap_or_default();
crate::terminal_title::set_action_required("zeph");
let coord_for_blocking = Arc::clone(&coord);
let Ok(Ok(result)) = tokio::task::spawn_blocking(move || {
line_editor::read_line_yieldable("You: ", &entries, &coord_for_blocking.elicit_active)
})
.await
else {
break;
};
crate::terminal_title::clear_action_required("zeph");
let line = match result {
ReadLineResult::Yielded => continue,
ReadLineResult::Interrupted | ReadLineResult::Eof => break,
ReadLineResult::Line(l) => l,
};
match process_line(line, true, &mut history, &mut pending_attachments).await {
Err(()) => break,
Ok(None) => {}
Ok(Some(msg)) => {
if tx.send(msg).await.is_err() {
break;
}
}
}
}
}
async fn run_piped_reader(mut history: Option<InputHistory>, tx: mpsc::Sender<ChannelMessage>) {
tracing::debug!("stdin is not a terminal, using piped input mode");
let (line_tx, mut line_rx) = mpsc::channel::<Result<ReadLineResult, std::io::Error>>(1);
std::thread::spawn(move || {
let stdin = std::io::stdin();
let mut reader = BufReader::new(stdin);
loop {
let result = line_editor::read_line_piped(&mut reader);
let is_eof = matches!(result, Ok(ReadLineResult::Eof));
if line_tx.blocking_send(result).is_err() || is_eof {
break;
}
}
});
let mut pending_attachments: Vec<Attachment> = Vec::new();
loop {
let Some(Ok(result)) = line_rx.recv().await else {
break;
};
let line = match result {
ReadLineResult::Interrupted | ReadLineResult::Eof => break,
ReadLineResult::Line(l) => l,
ReadLineResult::Yielded => continue,
};
match process_line(line, false, &mut history, &mut pending_attachments).await {
Err(()) => break,
Ok(None) => {}
Ok(Some(msg)) => {
if tx.send(msg).await.is_err() {
break;
}
}
}
}
}
fn spawn_stdin_reader(
is_tty: bool,
history: Option<InputHistory>,
tx: mpsc::Sender<ChannelMessage>,
coord: Arc<StdinCoordination>,
) {
tokio::spawn(async move {
if is_tty {
run_tty_reader(history, tx, coord).await;
} else {
run_piped_reader(history, tx).await;
}
});
}
struct PendingReader {
history: Option<InputHistory>,
is_tty: bool,
}
impl std::fmt::Debug for PendingReader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingReader")
.field("is_tty", &self.is_tty)
.finish_non_exhaustive()
}
}
#[derive(Debug)]
pub struct CliChannel {
accumulated: String,
input_rx: Option<mpsc::Receiver<ChannelMessage>>,
pending: Option<PendingReader>,
stdin_coord: Arc<StdinCoordination>,
}
impl CliChannel {
#[must_use]
pub fn new() -> Self {
let is_tty = std::io::stdin().is_terminal();
Self {
accumulated: String::new(),
input_rx: None,
pending: Some(PendingReader {
history: None,
is_tty,
}),
stdin_coord: Arc::new(StdinCoordination::new()),
}
}
#[must_use]
pub fn with_history(entries: Vec<String>, persist_fn: impl Fn(&str) + Send + 'static) -> Self {
let is_tty = std::io::stdin().is_terminal();
let history = InputHistory::new(entries, Box::new(persist_fn));
Self {
accumulated: String::new(),
input_rx: None,
pending: Some(PendingReader {
history: Some(history),
is_tty,
}),
stdin_coord: Arc::new(StdinCoordination::new()),
}
}
fn ensure_reader(&mut self) -> &mut mpsc::Receiver<ChannelMessage> {
if self.input_rx.is_none() {
let pending = self
.pending
.take()
.expect("PendingReader consumed before input_rx was set");
let (tx, rx) = mpsc::channel(STDIN_CHANNEL_CAPACITY);
spawn_stdin_reader(
pending.is_tty,
pending.history,
tx,
Arc::clone(&self.stdin_coord),
);
self.input_rx = Some(rx);
}
self.input_rx.as_mut().expect("input_rx set above")
}
}
impl Default for CliChannel {
fn default() -> Self {
Self::new()
}
}
impl Channel for CliChannel {
#[tracing::instrument(name = "channels.cli.recv", skip_all, fields(msg_len = tracing::field::Empty))]
async fn recv(&mut self) -> Result<Option<ChannelMessage>, ChannelError> {
Ok(self.ensure_reader().recv().await)
}
#[tracing::instrument(name = "channels.cli.send", skip_all, fields(msg_len = %text.len()))]
async fn send(&mut self, text: &str) -> Result<(), ChannelError> {
println!("Zeph: {text}");
Ok(())
}
#[tracing::instrument(name = "channels.cli.send_chunk", skip_all, fields(chunk_len = chunk.len()))]
async fn send_chunk(&mut self, chunk: &str) -> Result<(), ChannelError> {
use std::io::{Write, stdout};
print!("{chunk}");
stdout().flush()?;
self.accumulated.push_str(chunk);
Ok(())
}
#[tracing::instrument(name = "channels.cli.flush_chunks", skip_all)]
async fn flush_chunks(&mut self) -> Result<(), ChannelError> {
println!();
self.accumulated.clear();
Ok(())
}
#[tracing::instrument(name = "channels.cli.confirm", skip_all)]
async fn confirm(&mut self, prompt: &str) -> Result<bool, ChannelError> {
if !std::io::stdin().is_terminal() {
tracing::debug!("non-interactive stdin, auto-declining confirmation");
return Ok(false);
}
let _guard = ElicitGuard::acquire(&self.stdin_coord).await;
let prompt = format!("{prompt} [y/N]: ");
let result = tokio::task::spawn_blocking(move || line_editor::read_line(&prompt, &[]))
.await
.map_err(ChannelError::other)?
.map_err(ChannelError::Io)?;
match result {
ReadLineResult::Line(line) => Ok(line.trim().eq_ignore_ascii_case("y")),
ReadLineResult::Interrupted | ReadLineResult::Eof | ReadLineResult::Yielded => {
Ok(false)
}
}
}
#[tracing::instrument(name = "channels.cli.elicit", skip_all, fields(server = %request.server_name))]
async fn elicit(
&mut self,
request: ElicitationRequest,
) -> Result<ElicitationResponse, ChannelError> {
if !std::io::stdin().is_terminal() {
tracing::warn!(
server = request.server_name,
"non-interactive stdin, auto-declining elicitation"
);
return Ok(ElicitationResponse::Declined);
}
let _guard = ElicitGuard::acquire(&self.stdin_coord).await;
println!(
"\n[MCP server '{}' is requesting input]",
request.server_name
);
println!("{}", request.message);
let mut values = serde_json::Map::new();
for field in &request.fields {
let prompt = build_field_prompt(field);
let field_name = field.name.clone();
let result = tokio::task::spawn_blocking(move || line_editor::read_line(&prompt, &[]))
.await
.map_err(ChannelError::other)?
.map_err(ChannelError::Io)?;
match result {
ReadLineResult::Line(line) => {
let trimmed = line.trim().to_owned();
if let Some(value) = coerce_field_value(&trimmed, &field.field_type) {
values.insert(field_name, value);
} else {
println!(
"Invalid input for '{}' (expected {:?}), declining.",
field_name, field.field_type
);
return Ok(ElicitationResponse::Declined);
}
}
ReadLineResult::Interrupted | ReadLineResult::Eof | ReadLineResult::Yielded => {
return Ok(ElicitationResponse::Cancelled);
}
}
}
Ok(ElicitationResponse::Accepted(serde_json::Value::Object(
values,
)))
}
}
fn build_field_prompt(field: &ElicitationField) -> String {
let type_hint = match &field.field_type {
ElicitationFieldType::Boolean => " [true/false]",
ElicitationFieldType::Integer | ElicitationFieldType::Number => " [number]",
ElicitationFieldType::Enum(opts) if !opts.is_empty() => {
return format!(
"{}{}: ",
field.name,
field
.description
.as_deref()
.map(|d| format!(" ({d})"))
.unwrap_or_default()
) + &format!("[{}]: ", opts.join("/"));
}
_ => "",
};
format!(
"{}{}{}",
field.name,
field
.description
.as_deref()
.map(|d| format!(" ({d})"))
.unwrap_or_default(),
if type_hint.is_empty() {
": ".to_owned()
} else {
format!("{type_hint}: ")
}
)
}
fn coerce_field_value(raw: &str, field_type: &ElicitationFieldType) -> Option<serde_json::Value> {
match field_type {
ElicitationFieldType::String => Some(serde_json::Value::String(raw.to_owned())),
ElicitationFieldType::Boolean => match raw.to_ascii_lowercase().as_str() {
"true" | "yes" | "1" => Some(serde_json::Value::Bool(true)),
"false" | "no" | "0" => Some(serde_json::Value::Bool(false)),
_ => None,
},
ElicitationFieldType::Integer => raw
.parse::<i64>()
.ok()
.map(|n| serde_json::Value::Number(n.into())),
ElicitationFieldType::Number => raw
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(serde_json::Value::Number),
ElicitationFieldType::Enum(opts) => {
if opts.iter().any(|o| o == raw) {
Some(serde_json::Value::String(raw.to_owned()))
} else {
None
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::assert_matches;
#[test]
fn cli_channel_default() {
let ch = CliChannel::default();
let _ = format!("{ch:?}");
}
fn arm_ack_once(coord: &Arc<StdinCoordination>) {
let coord = Arc::clone(coord);
tokio::spawn(async move {
coord.parked_generation.fetch_add(1, Ordering::Release);
coord.ack.notify_one();
});
}
#[tokio::test]
async fn elicit_guard_acquire_sets_flag_true() {
let coord = Arc::new(StdinCoordination::new());
assert!(!coord.elicit_active.load(Ordering::Acquire));
arm_ack_once(&coord);
let _guard = ElicitGuard::acquire(&coord).await;
assert!(coord.elicit_active.load(Ordering::Acquire));
}
#[tokio::test]
async fn elicit_guard_drop_clears_flag() {
let coord = Arc::new(StdinCoordination::new());
arm_ack_once(&coord);
{
let _guard = ElicitGuard::acquire(&coord).await;
assert!(coord.elicit_active.load(Ordering::Acquire));
}
assert!(!coord.elicit_active.load(Ordering::Acquire));
}
#[tokio::test]
async fn elicit_guard_drop_wakes_a_notified_waiter() {
let coord = Arc::new(StdinCoordination::new());
arm_ack_once(&coord);
let guard = ElicitGuard::acquire(&coord).await;
let waiter_coord = Arc::clone(&coord);
let waiter = tokio::spawn(async move {
waiter_coord.resume.notified().await;
});
tokio::task::yield_now().await;
drop(guard);
tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
.await
.expect("waiter should wake within timeout")
.expect("waiter task should not panic");
}
#[tokio::test]
async fn elicit_guard_acquire_awaits_reader_ack_handshake() {
let coord = Arc::new(StdinCoordination::new());
let ack_fired = Arc::new(AtomicBool::new(false));
let acking_coord = Arc::clone(&coord);
let acking_flag = Arc::clone(&ack_fired);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
acking_flag.store(true, Ordering::Release);
acking_coord
.parked_generation
.fetch_add(1, Ordering::Release);
acking_coord.ack.notify_one();
});
let guard = ElicitGuard::acquire(&coord).await;
assert!(
ack_fired.load(Ordering::Acquire),
"acquire() must not return before observing the reader's ack"
);
drop(guard);
}
#[tokio::test]
async fn elicit_guard_acquire_does_not_hang_when_reader_never_acks() {
let coord = StdinCoordination::new();
let guard = tokio::time::timeout(Duration::from_secs(1), ElicitGuard::acquire(&coord))
.await
.expect("acquire() must not hang indefinitely when no reader ever acks");
drop(guard);
}
#[tokio::test]
async fn elicit_guard_acquire_rejects_stale_permit_from_prior_timed_out_acquire() {
let coord = Arc::new(StdinCoordination::new());
let guard1 = tokio::time::timeout(Duration::from_secs(1), ElicitGuard::acquire(&coord))
.await
.expect("first acquire() must not hang");
drop(guard1);
coord.parked_generation.fetch_add(1, Ordering::Release);
coord.ack.notify_one();
let acking_coord = Arc::clone(&coord);
let ack_fired = Arc::new(AtomicBool::new(false));
let acking_flag = Arc::clone(&ack_fired);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
acking_flag.store(true, Ordering::Release);
acking_coord
.parked_generation
.fetch_add(1, Ordering::Release);
acking_coord.ack.notify_one();
});
let guard2 = ElicitGuard::acquire(&coord).await;
assert!(
ack_fired.load(Ordering::Acquire),
"acquire() must not be satisfied by a stale permit left over from an earlier, \
unrelated park — it must wait for a fresh ack tied to its own request"
);
drop(guard2);
}
#[tokio::test]
async fn elicit_guard_acquire_cancelled_mid_await_clears_flag() {
let coord = Arc::new(StdinCoordination::new());
assert!(!coord.elicit_active.load(Ordering::Acquire));
tokio::select! {
_ = ElicitGuard::acquire(&coord) => {
panic!("acquire() must not resolve — nothing ever fires its ack");
}
() = tokio::task::yield_now() => {}
}
assert!(
!coord.elicit_active.load(Ordering::Acquire),
"dropping acquire() mid-await must still clear elicit_active via the guard's Drop"
);
}
async fn wait_for_resume(coord: &StdinCoordination) {
while coord.elicit_active.load(Ordering::Acquire) {
coord.parked_generation.fetch_add(1, Ordering::Release);
coord.ack.notify_one();
coord.resume.notified().await;
}
}
#[tokio::test]
async fn stdin_coord_wait_loop_blocks_while_guard_held_then_resumes_on_drop() {
let coord = Arc::new(StdinCoordination::new());
arm_ack_once(&coord);
let guard = ElicitGuard::acquire(&coord).await;
let waiter_coord = Arc::clone(&coord);
let waiter = tokio::spawn(async move { wait_for_resume(&waiter_coord).await });
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert!(
!waiter.is_finished(),
"wait loop must stay blocked while the guard is held"
);
drop(guard);
tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
.await
.expect("wait loop should exit within timeout after guard drop")
.expect("waiter task should not panic");
}
#[tokio::test]
async fn stdin_coord_wait_loop_ignores_spurious_notify_while_flag_still_true() {
let coord = Arc::new(StdinCoordination::new());
coord.elicit_active.store(true, Ordering::Release);
let waiter_coord = Arc::clone(&coord);
let waiter = tokio::spawn(async move { wait_for_resume(&waiter_coord).await });
tokio::task::yield_now().await;
coord.resume.notify_one();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert!(
!waiter.is_finished(),
"waiter must not exit while elicit_active remains true"
);
coord.elicit_active.store(false, Ordering::Release);
coord.resume.notify_one();
tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
.await
.expect("wait loop should exit within timeout")
.expect("waiter task should not panic");
}
#[tokio::test]
async fn cli_channel_send_chunk_accumulates() {
let mut ch = CliChannel::new();
ch.send_chunk("hello").await.unwrap();
ch.send_chunk(" ").await.unwrap();
ch.send_chunk("world").await.unwrap();
assert_eq!(ch.accumulated, "hello world");
}
#[tokio::test]
async fn cli_channel_flush_chunks_clears_buffer() {
let mut ch = CliChannel::new();
ch.send_chunk("test").await.unwrap();
ch.flush_chunks().await.unwrap();
assert!(ch.accumulated.is_empty());
}
#[test]
fn cli_channel_try_recv_returns_none() {
let mut ch = CliChannel::new();
assert!(ch.try_recv().is_none());
}
#[test]
fn cli_channel_new() {
let ch = CliChannel::new();
assert!(ch.accumulated.is_empty());
}
#[tokio::test]
async fn cli_channel_send_returns_ok() {
let mut ch = CliChannel::new();
ch.send("test message").await.unwrap();
}
#[tokio::test]
async fn cli_channel_flush_returns_ok() {
let mut ch = CliChannel::new();
ch.flush_chunks().await.unwrap();
}
#[tokio::test]
async fn image_command_valid_file_stores_in_pending() {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
let image_bytes = b"\x89PNG\r\n\x1a\nfake-image-data";
tmp.write_all(image_bytes).unwrap();
tmp.flush().unwrap();
let path = tmp.path().to_str().unwrap().to_owned();
let data = tokio::fs::read(&path).await.unwrap();
let filename = std::path::Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.map(str::to_owned);
let mut pending_attachments: Vec<Attachment> = Vec::new();
pending_attachments.push(Attachment {
kind: AttachmentKind::Image,
data: data.clone(),
filename,
});
assert_eq!(pending_attachments.len(), 1);
assert_eq!(pending_attachments[0].data, image_bytes);
assert_eq!(pending_attachments[0].kind, AttachmentKind::Image);
let taken = std::mem::take(&mut pending_attachments);
assert!(pending_attachments.is_empty());
assert_eq!(taken.len(), 1);
}
#[tokio::test]
async fn image_command_missing_file_is_handled_gracefully() {
let result = tokio::fs::read("/nonexistent/path/image.png").await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn image_command_empty_args_detected() {
let trimmed = "/image";
let arg = trimmed.strip_prefix("/image").map_or("", str::trim);
assert!(arg.is_empty());
let trimmed_space = "/image ";
let arg_space = trimmed_space.strip_prefix("/image").map_or("", str::trim);
assert!(arg_space.is_empty());
}
#[test]
fn cli_channel_new_has_empty_accumulated() {
let ch = CliChannel::new();
assert!(ch.accumulated.is_empty());
}
#[test]
fn cli_channel_with_history_constructs_ok() {
let ch = CliChannel::with_history(vec![], |_| {});
assert!(ch.accumulated.is_empty());
}
#[test]
fn input_history_add_and_dedup() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
let persisted = Arc::new(AtomicUsize::new(0));
let p = persisted.clone();
let mut history = InputHistory::new(
vec![],
Box::new(move |_| {
p.fetch_add(1, Ordering::Relaxed);
}),
);
history.add("hello");
history.add("hello"); history.add("world");
assert_eq!(history.entries().len(), 2);
assert_eq!(history.entries()[0], "hello");
assert_eq!(persisted.load(Ordering::Relaxed), 2);
}
#[test]
fn input_history_ignores_empty() {
let mut history = InputHistory::new(vec![], Box::new(|_| {}));
history.add("");
assert_eq!(history.entries().len(), 0);
}
#[tokio::test]
async fn recv_is_cancel_safe_via_mpsc_buffer() {
let (tx, rx) = mpsc::channel::<ChannelMessage>(32);
let mut ch = CliChannel {
accumulated: String::new(),
input_rx: Some(rx),
pending: None,
stdin_coord: Arc::new(StdinCoordination::new()),
};
tx.send(ChannelMessage {
text: "hello".to_string(),
attachments: vec![],
is_guest_context: false,
is_from_bot: false,
owner_key: None,
})
.await
.unwrap();
drop(ch.recv());
let result = ch.recv().await.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().text, "hello");
}
#[tokio::test]
async fn image_command_absolute_path_is_rejected() {
let mut pending: Vec<Attachment> = Vec::new();
let mut history = Some(InputHistory::new(vec![], Box::new(|_| {})));
let result = process_line(
"/image /etc/passwd".to_owned(),
false,
&mut history,
&mut pending,
)
.await;
assert_matches!(result, Ok(None));
assert!(pending.is_empty());
}
#[tokio::test]
async fn image_command_parent_dir_traversal_is_rejected() {
let mut pending: Vec<Attachment> = Vec::new();
let mut history = Some(InputHistory::new(vec![], Box::new(|_| {})));
let result = process_line(
"/image ../../../etc/passwd".to_owned(),
false,
&mut history,
&mut pending,
)
.await;
assert_matches!(result, Ok(None));
assert!(pending.is_empty());
}
#[test]
fn image_path_rejection_message_absolute() {
let msg = image_path_rejection_message(PathRejection::Absolute).unwrap();
assert!(msg.contains("absolute paths are not supported"));
}
#[test]
fn image_path_rejection_message_traversal() {
let msg = image_path_rejection_message(PathRejection::Traversal).unwrap();
assert!(msg.contains("path traversal") && msg.contains("not allowed"));
}
#[test]
fn image_path_rejection_message_allowed_is_none() {
assert!(image_path_rejection_message(PathRejection::Allowed).is_none());
}
}