tmux-expose 0.3.2

Mission Control-style tmux session switcher with live terminal previews
Documentation
use std::process::Command;

use anyhow::{Context, Result, anyhow};

use crate::model::Session;

const FIELD_SEPARATOR: char = ':';
const LEGACY_FIELD_SEPARATOR: char = '\u{1f}';
const SESSION_FORMAT: &str = "#{session_id}:#{session_name}:#{session_attached}:#{session_windows}:#{session_created}:#{session_activity}";

pub fn list_sessions() -> Result<Vec<Session>> {
    list_sessions_skipping_preview_for(None)
}

pub fn list_sessions_skipping_preview_for(
    current_session_id: Option<&str>,
) -> Result<Vec<Session>> {
    let output = Command::new("tmux")
        .args(["list-sessions", "-F", SESSION_FORMAT])
        .output()
        .context("failed to run tmux list-sessions")?;

    if !output.status.success() {
        return Err(tmux_error("tmux list-sessions failed", &output.stderr));
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut sessions = parse_sessions_output(&stdout)?;

    for session in &mut sessions {
        session.current_window = current_window_name(&session.id).unwrap_or(None);
        if !should_capture_preview(&session.id, current_session_id) {
            session.preview.clear();
            session.preview_error = Some("Current session preview disabled".to_string());
            continue;
        }

        match capture_session_preview(&session.id, 200) {
            Ok(preview) => {
                session.preview = preview;
                session.preview_error = None;
            }
            Err(error) => {
                session.preview.clear();
                session.preview_error = Some(error.to_string());
            }
        }
    }

    Ok(sessions)
}

pub fn current_session_name() -> Result<Option<String>> {
    let output = Command::new("tmux")
        .args(["display-message", "-p", "#S"])
        .output()
        .context("failed to run tmux display-message")?;

    if !output.status.success() {
        return Ok(None);
    }

    let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
    Ok((!name.is_empty()).then_some(name))
}

pub fn current_session_id() -> Result<Option<String>> {
    let output = Command::new("tmux")
        .args(["display-message", "-p", "#{session_id}"])
        .output()
        .context("failed to run tmux display-message")?;

    if !output.status.success() {
        return Ok(None);
    }

    let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
    Ok((!id.is_empty()).then_some(id))
}

pub fn current_window_name(session_target: &str) -> Result<Option<String>> {
    let target = format!("{}:", session_target);
    let output = Command::new("tmux")
        .args(["display-message", "-p", "-t", &target, "#W"])
        .output()
        .with_context(|| format!("failed to query active window for session '{session_target}'"))?;

    if !output.status.success() {
        return Ok(None);
    }

    let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
    Ok((!name.is_empty()).then_some(name))
}

pub fn capture_session_preview(session_target: &str, max_lines: usize) -> Result<Vec<String>> {
    let target = format!("{}:", session_target);
    let output = Command::new("tmux")
        .args(["capture-pane", "-e", "-p", "-t", &target])
        .output()
        .with_context(|| format!("failed to capture pane for session '{session_target}'"))?;

    if !output.status.success() {
        return Err(tmux_error("tmux capture-pane failed", &output.stderr));
    }

    Ok(trim_preview(
        String::from_utf8_lossy(&output.stdout).as_ref(),
        max_lines,
    ))
}

pub fn switch_client(session_target: &str) -> Result<()> {
    let status = Command::new("tmux")
        .args(["switch-client", "-t", session_target])
        .status()
        .with_context(|| format!("failed to switch to tmux session '{session_target}'"))?;

    if !status.success() {
        return Err(anyhow!("tmux switch-client failed for '{session_target}'"));
    }

    Ok(())
}

pub fn parse_sessions(output: &str) -> Vec<Session> {
    output
        .lines()
        .filter_map(|line| {
            let separator = if line.contains(FIELD_SEPARATOR) {
                FIELD_SEPARATOR
            } else {
                LEGACY_FIELD_SEPARATOR
            };
            let mut parts = line.splitn(6, separator);
            let id = parts.next()?.to_string();
            let name = parts.next()?.to_string();
            if name.is_empty() {
                return None;
            }

            let attached = parts.next().is_some_and(|value| value != "0");
            let window_count = parts
                .next()
                .and_then(|value| value.parse::<u32>().ok())
                .unwrap_or(0);
            let _created = parts.next();
            let last_activity = parts
                .next()
                .filter(|value| !value.is_empty())
                .map(ToString::to_string);

            Some(Session {
                id,
                name,
                attached,
                window_count,
                current_window: None,
                last_activity,
                preview: Vec::new(),
                preview_error: None,
            })
        })
        .collect()
}

fn parse_sessions_output(output: &str) -> Result<Vec<Session>> {
    let sessions = parse_sessions(output);
    if sessions.is_empty() && !output.trim().is_empty() {
        return Err(anyhow!(
            "tmux list-sessions returned output in an unexpected format"
        ));
    }

    Ok(sessions)
}

pub fn trim_preview(output: &str, max_lines: usize) -> Vec<String> {
    let mut lines: Vec<String> = output
        .lines()
        .map(|line| line.trim_end().to_string())
        .collect();

    while lines.last().is_some_and(|line| line.is_empty()) {
        lines.pop();
    }

    let start = lines.len().saturating_sub(max_lines);
    lines.into_iter().skip(start).collect()
}

fn tmux_error(message: &str, stderr: &[u8]) -> anyhow::Error {
    let stderr = String::from_utf8_lossy(stderr).trim().to_string();
    if stderr.is_empty() {
        anyhow!(message.to_string())
    } else {
        anyhow!("{message}: {stderr}")
    }
}

fn should_capture_preview(session_id: &str, current_session_id: Option<&str>) -> bool {
    current_session_id != Some(session_id)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_session_lines_from_tmux_format() {
        let sessions = parse_sessions(
            "$1\u{1f}dev\u{1f}1\u{1f}4\u{1f}1710000000\u{1f}1710000300\n$2\u{1f}logs\u{1f}0\u{1f}2\u{1f}1710000100\u{1f}1710000400\n",
        );

        assert_eq!(sessions.len(), 2);
        assert_eq!(sessions[0].id, "$1");
        assert_eq!(sessions[0].name, "dev");
        assert!(sessions[0].attached);
        assert_eq!(sessions[0].window_count, 4);
        assert_eq!(sessions[0].last_activity.as_deref(), Some("1710000300"));
        assert_eq!(sessions[1].name, "logs");
        assert!(!sessions[1].attached);
    }

    #[test]
    fn parses_session_names_containing_pipe_characters() {
        let sessions =
            parse_sessions("$3\u{1f}dev|api\u{1f}0\u{1f}1\u{1f}1710000000\u{1f}1710000300\n");

        assert_eq!(sessions[0].id, "$3");
        assert_eq!(sessions[0].name, "dev|api");
    }

    #[test]
    fn parses_colon_delimited_session_lines() {
        let sessions = parse_sessions("$3:dev|api:0:1:1710000000:1710000300\n");

        assert_eq!(sessions.len(), 1);
        assert_eq!(sessions[0].id, "$3");
        assert_eq!(sessions[0].name, "dev|api");
        assert!(!sessions[0].attached);
        assert_eq!(sessions[0].window_count, 1);
        assert_eq!(sessions[0].last_activity.as_deref(), Some("1710000300"));
    }

    #[test]
    fn rejects_non_empty_unparseable_session_output() {
        let error = parse_sessions_output("$3dev011710000000171000300\n")
            .unwrap_err()
            .to_string();

        assert!(error.contains("unexpected format"));
    }

    #[test]
    fn trims_preview_to_last_non_empty_visible_lines() {
        let preview = trim_preview("first\nsecond\nthird\n\n", 2);

        assert_eq!(preview, vec!["second".to_string(), "third".to_string()]);
    }

    #[test]
    fn preserves_ansi_escape_sequences_from_preview() {
        let preview = trim_preview("\u{1b}[31mred\u{1b}[0m plain", 5);

        assert_eq!(preview, vec!["\u{1b}[31mred\u{1b}[0m plain".to_string()]);
    }

    #[test]
    fn skips_preview_capture_for_current_session() {
        assert!(!should_capture_preview("$1", Some("$1")));
        assert!(should_capture_preview("$2", Some("$1")));
        assert!(should_capture_preview("$1", None));
    }
}