use std::path::PathBuf;
use anyhow::Result;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub fn state_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(xdg).join("tmai")
} else {
let uid = unsafe { libc::getuid() };
PathBuf::from(format!("/tmp/tmai-{}", uid))
}
}
pub fn data_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
PathBuf::from(xdg).join("tmai")
} else if let Some(home) = dirs::home_dir() {
home.join(".local").join("share").join("tmai")
} else {
state_dir()
}
}
pub fn socket_path() -> PathBuf {
state_dir().join("control.sock")
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WrapStatus {
Processing,
#[default]
Idle,
AwaitingApproval,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WrapApprovalType {
FileEdit,
ShellCommand,
McpTool,
UserQuestion,
YesNo,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WrapState {
pub status: WrapStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_type: Option<WrapApprovalType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub choices: Vec<String>,
#[serde(default)]
pub multi_select: bool,
#[serde(default)]
pub cursor_position: usize,
pub last_output: u64,
pub last_input: u64,
pub pid: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub pane_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub team_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub team_member_name: Option<String>,
#[serde(default)]
pub is_team_lead: bool,
}
impl Default for WrapState {
fn default() -> Self {
let now = current_time_millis();
Self {
status: WrapStatus::Idle,
approval_type: None,
details: None,
choices: Vec::new(),
multi_select: false,
cursor_position: 0,
last_output: now,
last_input: now,
pid: 0,
pane_id: None,
team_name: None,
team_member_name: None,
is_team_lead: false,
}
}
}
impl WrapState {
pub fn processing(pid: u32) -> Self {
Self {
status: WrapStatus::Processing,
pid,
..Default::default()
}
}
pub fn idle(pid: u32) -> Self {
Self {
status: WrapStatus::Idle,
pid,
..Default::default()
}
}
pub fn awaiting_approval(
pid: u32,
approval_type: WrapApprovalType,
details: Option<String>,
) -> Self {
Self {
status: WrapStatus::AwaitingApproval,
approval_type: Some(approval_type),
details,
pid,
..Default::default()
}
}
pub fn user_question(
pid: u32,
choices: Vec<String>,
multi_select: bool,
cursor_position: usize,
) -> Self {
Self {
status: WrapStatus::AwaitingApproval,
approval_type: Some(WrapApprovalType::UserQuestion),
choices,
multi_select,
cursor_position,
pid,
..Default::default()
}
}
pub fn touch_output(&mut self) {
self.last_output = current_time_millis();
}
pub fn touch_input(&mut self) {
self.last_input = current_time_millis();
}
pub fn with_pane_id(mut self, pane_id: String) -> Self {
self.pane_id = Some(pane_id);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ClientMessage {
Register {
pane_id: String,
pid: u32,
#[serde(skip_serializing_if = "Option::is_none")]
team_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
team_member_name: Option<String>,
#[serde(default)]
is_team_lead: bool,
},
StateUpdate { state: WrapState },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ServerMessage {
Registered { connection_id: String },
SendKeys { keys: String, literal: bool },
SendKeysAndEnter { text: String },
}
pub fn encode<T: Serialize>(msg: &T) -> Result<Vec<u8>> {
let mut json = serde_json::to_vec(msg)?;
json.push(b'\n');
Ok(json)
}
pub fn decode<T: DeserializeOwned>(line: &[u8]) -> Result<T> {
Ok(serde_json::from_slice(line)?)
}
pub fn current_time_millis() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wrap_state_serialization() {
let state = WrapState::processing(1234);
let json = serde_json::to_string(&state).unwrap();
assert!(json.contains("\"status\":\"processing\""));
assert!(json.contains("\"pid\":1234"));
}
#[test]
fn test_wrap_state_deserialization() {
let json = r#"{
"status": "awaiting_approval",
"approval_type": "user_question",
"choices": ["Yes", "No"],
"multi_select": false,
"cursor_position": 1,
"last_output": 1234567890,
"last_input": 1234567890,
"pid": 5678
}"#;
let state: WrapState = serde_json::from_str(json).unwrap();
assert_eq!(state.status, WrapStatus::AwaitingApproval);
assert_eq!(state.approval_type, Some(WrapApprovalType::UserQuestion));
assert_eq!(state.choices, vec!["Yes", "No"]);
assert_eq!(state.cursor_position, 1);
assert_eq!(state.pid, 5678);
}
#[test]
fn test_current_time_millis() {
let t1 = current_time_millis();
std::thread::sleep(std::time::Duration::from_millis(10));
let t2 = current_time_millis();
assert!(t2 > t1);
}
#[test]
fn test_client_message_register_serialization() {
let msg = ClientMessage::Register {
pane_id: "5".to_string(),
pid: 1234,
team_name: Some("my-team".to_string()),
team_member_name: Some("dev".to_string()),
is_team_lead: false,
};
let encoded = encode(&msg).unwrap();
let decoded: ClientMessage = decode(encoded.trim_ascii_end()).unwrap();
match decoded {
ClientMessage::Register { pane_id, pid, .. } => {
assert_eq!(pane_id, "5");
assert_eq!(pid, 1234);
}
_ => panic!("Expected Register"),
}
}
#[test]
fn test_server_message_send_keys_serialization() {
let msg = ServerMessage::SendKeys {
keys: "y".to_string(),
literal: true,
};
let encoded = encode(&msg).unwrap();
let decoded: ServerMessage = decode(encoded.trim_ascii_end()).unwrap();
match decoded {
ServerMessage::SendKeys { keys, literal } => {
assert_eq!(keys, "y");
assert!(literal);
}
_ => panic!("Expected SendKeys"),
}
}
#[test]
fn test_state_dir_default() {
temp_env::with_var_unset("XDG_RUNTIME_DIR", || {
let dir = state_dir();
let uid = unsafe { libc::getuid() };
assert_eq!(dir, PathBuf::from(format!("/tmp/tmai-{}", uid)));
});
}
#[test]
fn test_state_dir_with_xdg() {
temp_env::with_var("XDG_RUNTIME_DIR", Some("/run/user/1000"), || {
let dir = state_dir();
assert_eq!(dir, PathBuf::from("/run/user/1000/tmai"));
});
}
#[test]
fn test_socket_path_contains_control_sock() {
let path = socket_path();
assert!(path.ends_with("control.sock"));
}
#[test]
fn test_encode_decode_roundtrip() {
let state = WrapState::user_question(42, vec!["A".into(), "B".into()], true, 1);
let msg = ClientMessage::StateUpdate { state };
let encoded = encode(&msg).unwrap();
assert!(encoded.ends_with(b"\n"));
let decoded: ClientMessage = decode(encoded.trim_ascii_end()).unwrap();
match decoded {
ClientMessage::StateUpdate { state } => {
assert_eq!(state.pid, 42);
assert_eq!(state.choices, vec!["A", "B"]);
assert!(state.multi_select);
}
_ => panic!("Expected StateUpdate"),
}
}
}