use std::path::Path;
use std::process::Command;
use crate::core::external_session::ExternalSession;
use crate::core::session::Session;
use crate::core::tmux::TmuxTarget;
use crate::daemon::error::DaemonError;
use crate::daemon::tmux::{AdoptedSession, SessionSnapshot, TmuxDriver};
pub struct TmuxService;
impl TmuxService {
pub fn capture(session: &Session, lines: u32) -> String {
match TmuxDriver::discover() {
Ok(driver) => {
let target = TmuxTarget::session(&session.tmux_name);
match driver.capture(&target, Some(lines)) {
Ok(text) => text,
Err(e) => {
tracing::warn!("tmux capture failed for {}: {e}", session.tmux_name);
String::new()
}
}
}
Err(_) => {
tracing::info!(
"tmux unavailable; capture for {} skipped",
session.tmux_name
);
String::new()
}
}
}
pub fn send_command(session: &Session, command: &str) {
match TmuxDriver::discover() {
Ok(driver) => {
let target = TmuxTarget::session(&session.tmux_name);
if let Err(e) = driver.send_line(&target, command) {
tracing::warn!("tmux send_line failed for {}: {e}", session.tmux_name);
}
}
Err(_) => {
tracing::info!(
"tmux unavailable; command for {} not sent",
session.tmux_name
);
}
}
}
pub fn list_all() -> Vec<ExternalSession> {
match TmuxDriver::discover() {
Ok(driver) => driver.list_all_sessions().unwrap_or_else(|e| {
tracing::warn!("tmux list_all_sessions failed: {e}");
Vec::new()
}),
Err(_) => {
tracing::info!("tmux unavailable; tmux session list is empty");
Vec::new()
}
}
}
pub fn adopt(name: &str) -> Result<AdoptedSession, DaemonError> {
let driver = TmuxDriver::discover().map_err(|_| DaemonError::SessionNotFound {
id: name.to_string(),
})?;
driver.adopt_session(name).map_err(|e| {
tracing::warn!("tmux adopt {name} failed: {e}");
DaemonError::SessionNotFound {
id: name.to_string(),
}
})
}
pub fn spawn_claude(tmux_name: &str, workdir: &Path) -> Result<(), DaemonError> {
if which_claude().is_none() {
return Err(DaemonError::Unprocessable(
"claude binary not found on PATH".to_string(),
));
}
let driver = TmuxDriver::discover()
.map_err(|e| DaemonError::Unprocessable(format!("tmux unavailable for spawn: {e}")))?;
let workdir_str = workdir.to_string_lossy().into_owned();
driver
.create_session(tmux_name, Some(&workdir_str))
.map_err(|e| {
DaemonError::Internal(format!("tmux new-session for {tmux_name} failed: {e}"))
})?;
let target = TmuxTarget::session(tmux_name);
driver.send_line(&target, "claude").map_err(|e| {
DaemonError::Internal(format!("failed to launch claude in {tmux_name}: {e}"))
})?;
Ok(())
}
pub fn snapshot(name: &str, lines: u32) -> Result<SessionSnapshot, DaemonError> {
let driver = TmuxDriver::discover().map_err(|_| DaemonError::SessionNotFound {
id: name.to_string(),
})?;
driver.monitor_session(name, lines).map_err(|e| {
tracing::warn!("tmux snapshot for {name} failed: {e}");
DaemonError::SessionNotFound {
id: name.to_string(),
}
})
}
}
fn which_claude() -> Option<String> {
#[cfg(test)]
if let Some(override_value) = claude_lookup_override() {
return override_value;
}
let output = Command::new("which").arg("claude").output().ok()?;
if !output.status.success() {
return None;
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() { None } else { Some(path) }
}
#[cfg(test)]
static CLAUDE_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
static CLAUDE_OVERRIDE: std::sync::RwLock<Option<Option<String>>> = std::sync::RwLock::new(None);
#[cfg(test)]
pub(crate) fn claude_lookup_override() -> Option<Option<String>> {
CLAUDE_OVERRIDE.read().ok().and_then(|g| g.clone())
}
#[cfg(test)]
pub(crate) struct ClaudeOverrideGuard {
_lock: std::sync::MutexGuard<'static, ()>,
}
#[cfg(test)]
impl Drop for ClaudeOverrideGuard {
fn drop(&mut self) {
if let Ok(mut guard) = CLAUDE_OVERRIDE.write() {
*guard = None;
}
}
}
#[cfg(test)]
pub(crate) fn set_claude_lookup_override(value: Option<Option<String>>) -> ClaudeOverrideGuard {
let lock = match CLAUDE_TEST_MUTEX.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
if let Ok(mut guard) = CLAUDE_OVERRIDE.write() {
*guard = value;
}
ClaudeOverrideGuard { _lock: lock }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::session::{ControlModel, SessionId};
#[test]
fn capture_without_tmux_is_empty() {
let session = Session::new(SessionId::new(), "/tmp/p", ControlModel::Tmux, None);
let _ = TmuxService::capture(&session, 10);
}
#[test]
fn list_all_without_tmux_is_empty() {
let _ = TmuxService::list_all();
}
#[test]
fn adopt_missing_session_is_not_found() {
let result = TmuxService::adopt("tmpm-definitely-no-such-session-xyz");
assert!(result.is_err());
}
#[test]
fn snapshot_missing_session_is_not_found() {
let result = TmuxService::snapshot("no-such-session-xyz", 10);
assert!(result.is_err());
}
#[test]
fn spawn_claude_without_binary_is_unprocessable() {
let _guard = set_claude_lookup_override(Some(None));
let result = TmuxService::spawn_claude("tmpm-test-no-bin", Path::new("/tmp"));
match result {
Err(DaemonError::Unprocessable(msg)) => {
assert!(
msg.contains("claude binary"),
"message should name the missing binary: {msg}"
);
}
other => panic!("expected Unprocessable, got {other:?}"),
}
}
#[test]
fn spawn_claude_without_tmux_is_unprocessable_when_tmux_missing() {
let _guard = set_claude_lookup_override(Some(Some("/fake/claude".into())));
let result = TmuxService::spawn_claude("tmpm-test-no-tmux", Path::new("/tmp"));
if TmuxDriver::is_available() {
if result.is_ok()
&& let Ok(driver) = TmuxDriver::discover()
{
let _ = driver.kill_session("tmpm-test-no-tmux");
}
} else {
assert!(
matches!(result, Err(DaemonError::Unprocessable(_))),
"expected Unprocessable on no-tmux host, got {result:?}"
);
}
}
}