use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Write};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Mutex};
pub(crate) const CONTROL_ENV: &str = "TUI_LIPAN_CONTROL";
pub(crate) type ControlQueue = Arc<Mutex<VecDeque<ControlRequest>>>;
pub(crate) struct ControlRequest {
pub(crate) command: String,
pub(crate) reply: Sender<ControlReply>,
}
pub(crate) enum ControlReply {
Ok(String),
Err(String),
}
impl ControlReply {
fn encode(&self) -> Vec<u8> {
let (status, payload) = match self {
Self::Ok(payload) => ("ok", payload.as_str()),
Self::Err(message) => ("err", message.as_str()),
};
let mut out = format!("{status} {}\n", payload.len()).into_bytes();
out.extend_from_slice(payload.as_bytes());
out
}
}
pub(crate) fn control_path() -> Option<std::path::PathBuf> {
std::env::var_os(CONTROL_ENV)
.filter(|path| !path.is_empty())
.map(std::path::PathBuf::from)
}
pub(crate) struct ControlGuard {
path: std::path::PathBuf,
}
impl Drop for ControlGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[cfg(unix)]
pub(crate) fn spawn(
path: std::path::PathBuf,
queue: ControlQueue,
events: Sender<super::RunnerEvent>,
) -> crate::Result<ControlGuard> {
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixListener;
let _ = std::fs::remove_file(&path);
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
let listener = UnixListener::bind(&path)?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
std::thread::Builder::new()
.name("tui-lipan-control".into())
.spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { break };
if serve(stream, &queue, &events).is_err() {
break;
}
}
})?;
Ok(ControlGuard { path })
}
#[cfg(not(unix))]
pub(crate) fn spawn(
_path: std::path::PathBuf,
_queue: ControlQueue,
_events: Sender<super::RunnerEvent>,
) -> crate::Result<ControlGuard> {
Err(std::io::Error::other("TUI_LIPAN_CONTROL requires Unix domain sockets").into())
}
#[cfg(unix)]
fn serve(
stream: std::os::unix::net::UnixStream,
queue: &ControlQueue,
events: &Sender<super::RunnerEvent>,
) -> Result<(), ()> {
let mut writer = match stream.try_clone() {
Ok(stream) => stream,
Err(_) => return Ok(()),
};
let reader = BufReader::new(stream);
for line in reader.lines() {
let Ok(command) = line else { return Ok(()) };
let command = command.trim().to_owned();
if command.is_empty() {
continue;
}
let reply = match exchange(command, queue, events) {
Ok(reply) => reply,
Err(()) => return Err(()),
};
if writer.write_all(&reply.encode()).is_err() || writer.flush().is_err() {
return Ok(());
}
}
Ok(())
}
fn exchange(
command: String,
queue: &ControlQueue,
events: &Sender<super::RunnerEvent>,
) -> Result<ControlReply, ()> {
let (reply_tx, reply_rx): (Sender<ControlReply>, Receiver<ControlReply>) = channel();
queue.lock().map_err(|_| ())?.push_back(ControlRequest {
command,
reply: reply_tx,
});
events.send(super::RunnerEvent::Control).map_err(|_| ())?;
reply_rx.recv().map_err(|_| ())
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum ControlCommand {
Ping,
Keys,
Snapshot(SnapshotFormat),
Act(String),
Highlight(Option<HighlightTarget>),
Quit,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum HighlightTarget {
Key(String),
Cell(u16, u16),
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum SnapshotFormat {
Markdown,
Json,
Png(std::path::PathBuf),
}
pub(crate) fn parse_command(line: &str) -> Result<ControlCommand, String> {
let line = line.trim();
let (verb, rest) = match line.split_once(char::is_whitespace) {
Some((verb, rest)) => (verb, rest.trim()),
None => (line, ""),
};
match verb {
"ping" => Ok(ControlCommand::Ping),
"keys" => Ok(ControlCommand::Keys),
"quit" => Ok(ControlCommand::Quit),
"highlight" => match rest {
"" | "clear" | "off" | "none" => Ok(ControlCommand::Highlight(None)),
target => match target.split_once(',') {
Some((x, y)) => {
let x = x
.trim()
.parse()
.map_err(|_| format!("invalid highlight column in `{target}`"))?;
let y = y
.trim()
.parse()
.map_err(|_| format!("invalid highlight row in `{target}`"))?;
Ok(ControlCommand::Highlight(Some(HighlightTarget::Cell(x, y))))
}
None => Ok(ControlCommand::Highlight(Some(HighlightTarget::Key(
target.trim_start_matches('#').to_owned(),
)))),
},
},
"act" => {
if rest.is_empty() {
return Err("act needs a script, e.g. `act click:#submit`".into());
}
Ok(ControlCommand::Act(rest.to_owned()))
}
"snapshot" => match rest {
"" | "md" | "markdown" => Ok(ControlCommand::Snapshot(SnapshotFormat::Markdown)),
"json" => Ok(ControlCommand::Snapshot(SnapshotFormat::Json)),
other => match other.split_once(char::is_whitespace) {
Some(("png", path)) if !path.trim().is_empty() => Ok(ControlCommand::Snapshot(
SnapshotFormat::Png(std::path::PathBuf::from(path.trim())),
)),
_ => Err(format!(
"unknown snapshot format `{other}`; expected markdown, json, or `png <path>`"
)),
},
},
other => Err(format!(
"unknown command `{other}`; expected ping, keys, snapshot, act, highlight, or quit"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replies_are_status_line_plus_length_prefixed_payload() {
assert_eq!(ControlReply::Ok("hi".into()).encode(), b"ok 2\nhi".to_vec());
assert_eq!(ControlReply::Ok(String::new()).encode(), b"ok 0\n".to_vec());
assert_eq!(
ControlReply::Err("nope".into()).encode(),
b"err 4\nnope".to_vec()
);
}
#[test]
fn payloads_with_newlines_need_no_escaping() {
let payload = "line one\nline two\n";
let encoded = ControlReply::Ok(payload.into()).encode();
assert!(encoded.starts_with(b"ok 18\n"));
assert!(encoded.ends_with(payload.as_bytes()));
}
#[test]
fn simple_verbs_parse() {
assert_eq!(parse_command("ping"), Ok(ControlCommand::Ping));
assert_eq!(parse_command(" keys "), Ok(ControlCommand::Keys));
assert_eq!(parse_command("quit"), Ok(ControlCommand::Quit));
}
#[test]
fn snapshot_defaults_to_markdown_and_accepts_formats() {
assert_eq!(
parse_command("snapshot"),
Ok(ControlCommand::Snapshot(SnapshotFormat::Markdown))
);
assert_eq!(
parse_command("snapshot md"),
Ok(ControlCommand::Snapshot(SnapshotFormat::Markdown))
);
assert_eq!(
parse_command("snapshot json"),
Ok(ControlCommand::Snapshot(SnapshotFormat::Json))
);
assert_eq!(
parse_command("snapshot png /tmp/a.png"),
Ok(ControlCommand::Snapshot(SnapshotFormat::Png(
"/tmp/a.png".into()
)))
);
}
#[test]
fn act_keeps_the_whole_script_including_spaces() {
assert_eq!(
parse_command("act click:#add; type:buy milk"),
Ok(ControlCommand::Act("click:#add; type:buy milk".into()))
);
}
#[test]
fn highlight_takes_a_key_or_clears() {
assert_eq!(
parse_command("highlight add"),
Ok(ControlCommand::Highlight(Some(HighlightTarget::Key(
"add".into()
))))
);
assert_eq!(
parse_command("highlight #add"),
Ok(ControlCommand::Highlight(Some(HighlightTarget::Key(
"add".into()
))))
);
assert_eq!(
parse_command("highlight 61,2"),
Ok(ControlCommand::Highlight(Some(HighlightTarget::Cell(
61, 2
))))
);
assert!(parse_command("highlight x,2").is_err());
for clearing in ["highlight", "highlight clear", "highlight off"] {
assert_eq!(
parse_command(clearing),
Ok(ControlCommand::Highlight(None)),
"{clearing}"
);
}
}
#[test]
fn malformed_commands_explain_themselves() {
for line in ["frobnicate", "act", "snapshot sideways", "snapshot png"] {
let err = parse_command(line).expect_err(line);
assert!(!err.is_empty(), "{line} should explain the problem");
}
assert!(
parse_command("frobnicate")
.unwrap_err()
.contains("expected")
);
assert!(parse_command("act").unwrap_err().contains("needs a script"));
}
}