rmux-server 0.10.0

Tokio daemon and request dispatcher for the RMUX terminal multiplexer.
Documentation
use std::sync::Arc;

use rmux_core::{command_parser::ParsedCommand, SessionStore, TargetFindContext};
use rmux_proto::{Request, RmuxError, Target};

use super::super::lifecycle_support::{LeaseResolution, LifecycleTargetLease};
use super::super::StableTargetIdentity;
use super::implicit_pane_target;
use super::queue::QueueInvocation;
use super::queue_exact_target::has_short_option_value;
use crate::pane_terminals::HandlerState;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RoleUse {
    Unused,
    Explicit,
    Implicit,
}

#[derive(Debug)]
pub(super) struct QueueLifecycleTargetPlan {
    command: &'static str,
    primary: RoleUse,
    source: RoleUse,
    destination: RoleUse,
}

#[derive(Debug, Default)]
pub(super) struct QueueLifecycleTargetCapture {
    identities: Vec<StableTargetIdentity>,
    retained_target: Option<Arc<LifecycleTargetLease>>,
    retained_identity: Option<StableTargetIdentity>,
}

impl QueueLifecycleTargetPlan {
    pub(super) fn for_command(command: &ParsedCommand) -> Result<Option<Self>, RmuxError> {
        let target = || role_for_flag(command, 't');
        let plan = match command.name() {
            "rename-window" => Self::primary("rename-window", target()?),
            "capture-pane" => Self::primary("capture-pane", target()?),
            "new-window" => Self::primary("new-window", target()?),
            "lock-session" => Self::primary("lock-session", target()?),
            "send-keys" => {
                let primary = if has_flag(command, 'c')? && !has_flag(command, 't')? {
                    RoleUse::Unused
                } else {
                    target()?
                };
                Self::primary("send-keys", primary)
            }
            "display-message" => {
                let primary = if has_flag(command, 'c')? && !has_flag(command, 't')? {
                    RoleUse::Unused
                } else {
                    target()?
                };
                Self::primary("display-message", primary)
            }
            "join-pane" => Self {
                command: "join-pane",
                primary: RoleUse::Unused,
                source: role_for_flag(command, 's')?,
                destination: target()?,
            },
            _ => return Ok(None),
        };
        Ok(Some(plan))
    }

    fn primary(command: &'static str, primary: RoleUse) -> Self {
        Self {
            command,
            primary,
            source: RoleUse::Unused,
            destination: RoleUse::Unused,
        }
    }

    pub(super) fn resolve_parse_target(
        &self,
        retained_target: &Arc<LifecycleTargetLease>,
        state: &HandlerState,
    ) -> Result<Option<Target>, RmuxError> {
        if !self.uses_any_role() {
            return Ok(None);
        }
        match retained_target.resolve(state) {
            LeaseResolution::Live(target) => Ok(Some(target)),
            LeaseResolution::Retired(_) if self.uses_implicit_role() => {
                Err(RmuxError::Server(format!(
                    "queued {} lifecycle target retired before parsing",
                    self.command
                )))
            }
            LeaseResolution::Replaced if self.uses_implicit_role() => {
                Err(RmuxError::Server(format!(
                    "queued {} lifecycle target was replaced before parsing",
                    self.command
                )))
            }
            LeaseResolution::Retired(_) | LeaseResolution::Replaced => Ok(None),
        }
    }

    pub(super) fn bind_implicit_target(
        &self,
        invocation: &mut QueueInvocation,
        sessions: &SessionStore,
        find_context: &TargetFindContext,
    ) -> Result<(), RmuxError> {
        if self.command != "send-keys" || self.primary != RoleUse::Implicit {
            return Ok(());
        }
        let target = implicit_pane_target(sessions, find_context, "send-keys")?;
        match invocation {
            QueueInvocation::Request(Request::SendKeysExt(request)) if request.target.is_none() => {
                request.target = Some(target);
            }
            QueueInvocation::Request(Request::SendKeysExt2(request))
                if request.target.is_none() =>
            {
                request.target = Some(target);
            }
            QueueInvocation::Request(Request::SendKeys(request)) if request.target == target => {}
            _ => {
                return Err(RmuxError::Server(
                    "queued send-keys lifecycle target could not be bound".to_owned(),
                ));
            }
        }
        Ok(())
    }

    pub(super) fn capture(
        self,
        invocation: &QueueInvocation,
        state: &mut HandlerState,
        retained_target: Arc<LifecycleTargetLease>,
    ) -> Result<QueueLifecycleTargetCapture, RmuxError> {
        let retained_identity = if self.uses_implicit_role() {
            match retained_target.resolve(state) {
                LeaseResolution::Live(target) => {
                    Some(StableTargetIdentity::capture(state, target)?)
                }
                LeaseResolution::Retired(_) => {
                    return Err(RmuxError::Server(format!(
                        "queued {} lifecycle target retired during parsing",
                        self.command
                    )))
                }
                LeaseResolution::Replaced => {
                    return Err(RmuxError::Server(format!(
                        "queued {} lifecycle target was replaced during parsing",
                        self.command
                    )))
                }
            }
        } else {
            None
        };
        let mut targets = Vec::new();
        match (self.command, invocation) {
            ("rename-window", QueueInvocation::Request(Request::RenameWindow(request))) => {
                push_used(
                    &mut targets,
                    self.primary,
                    Target::Window(request.target.clone()),
                );
            }
            ("send-keys", QueueInvocation::Request(request)) => {
                if self.primary != RoleUse::Unused {
                    let target =
                        send_keys_target(request).ok_or_else(|| role_unavailable(self.command))?;
                    targets.push(Target::Pane(target));
                }
            }
            ("join-pane", QueueInvocation::Request(Request::JoinPane(request))) => {
                push_used(
                    &mut targets,
                    self.source,
                    Target::Pane(request.source.clone()),
                );
                push_used(
                    &mut targets,
                    self.destination,
                    Target::Pane(request.target.clone()),
                );
            }
            ("new-window", QueueInvocation::NewWindow(command)) => {
                push_used(
                    &mut targets,
                    self.primary,
                    Target::Session(command.target.clone()),
                );
            }
            ("lock-session", QueueInvocation::Request(Request::LockSession(request))) => {
                push_used(
                    &mut targets,
                    self.primary,
                    Target::Session(request.target.clone()),
                );
            }
            ("display-message", QueueInvocation::Request(request)) => {
                if self.primary != RoleUse::Unused {
                    targets.push(
                        display_message_target(request)
                            .ok_or_else(|| role_unavailable(self.command))?,
                    );
                }
            }
            ("capture-pane", QueueInvocation::Request(Request::CapturePane(request))) => {
                push_used(
                    &mut targets,
                    self.primary,
                    Target::Pane(request.target.clone()),
                );
            }
            _ => return Err(role_unavailable(self.command)),
        }

        let identities = targets
            .into_iter()
            .map(|target| StableTargetIdentity::capture(state, target))
            .collect::<Result<Vec<_>, _>>()?;
        Ok(QueueLifecycleTargetCapture {
            identities,
            retained_target: self.uses_implicit_role().then_some(retained_target),
            retained_identity,
        })
    }

    fn uses_implicit_role(&self) -> bool {
        [self.primary, self.source, self.destination].contains(&RoleUse::Implicit)
    }

    fn uses_any_role(&self) -> bool {
        [self.primary, self.source, self.destination]
            .into_iter()
            .any(|role| role != RoleUse::Unused)
    }
}

impl QueueLifecycleTargetCapture {
    pub(super) fn into_parts(
        self,
    ) -> (
        Vec<StableTargetIdentity>,
        Option<Arc<LifecycleTargetLease>>,
        Option<StableTargetIdentity>,
    ) {
        (
            self.identities,
            self.retained_target,
            self.retained_identity,
        )
    }
}

fn role_for_flag(command: &ParsedCommand, flag: char) -> Result<RoleUse, RmuxError> {
    Ok(if has_flag(command, flag)? {
        RoleUse::Explicit
    } else {
        RoleUse::Implicit
    })
}

fn has_flag(command: &ParsedCommand, flag: char) -> Result<bool, RmuxError> {
    has_short_option_value(command, flag).ok_or_else(|| {
        RmuxError::Server(format!(
            "queued {} lifecycle guard has no short-option inventory",
            command.name()
        ))
    })
}

fn push_used(targets: &mut Vec<Target>, role: RoleUse, target: Target) {
    if role != RoleUse::Unused {
        targets.push(target);
    }
}

fn send_keys_target(request: &Request) -> Option<rmux_proto::PaneTarget> {
    match request {
        Request::SendKeys(request) => Some(request.target.clone()),
        Request::SendKeysExt(request) => request.target.clone(),
        Request::SendKeysExt2(request) => request.target.clone(),
        _ => None,
    }
}

fn display_message_target(request: &Request) -> Option<Target> {
    match request {
        Request::DisplayMessage(request) => request.target.clone(),
        Request::DisplayMessageExt(request) => request.target.clone(),
        _ => None,
    }
}

fn role_unavailable(command: &str) -> RmuxError {
    RmuxError::Server(format!(
        "queued {command} lifecycle target role was unavailable after parsing"
    ))
}

#[cfg(test)]
mod tests {
    use rmux_core::command_parser::CommandParser;

    use super::*;

    fn plan(command: &str) -> Option<QueueLifecycleTargetPlan> {
        let parsed = CommandParser::new().parse(command).expect("command parses");
        QueueLifecycleTargetPlan::for_command(&parsed.commands()[0])
            .expect("covered command has option inventory")
    }

    #[test]
    fn normal_path_scope_is_explicit_and_complete() {
        for command in [
            "rename-window name",
            "send-keys",
            "join-pane",
            "new-window",
            "lock-session",
            "display-message -p",
            "capture-pane -p",
        ] {
            assert!(plan(command).is_some(), "{command}");
        }
        for special in [
            "command-prompt -t client",
            "confirm-before -t client 'display-message ok'",
            "display-panes -t client",
            "if-shell true ''",
            "source-file file.conf",
            "run-shell true",
        ] {
            assert!(plan(special).is_none(), "{special}");
        }
    }

    #[test]
    fn join_roles_track_source_and_destination_independently() {
        let source = plan("join-pane -s alpha:0.0").expect("join plan");
        assert_eq!(source.source, RoleUse::Explicit);
        assert_eq!(source.destination, RoleUse::Implicit);

        let destination = plan("join-pane -t beta:0.0").expect("join plan");
        assert_eq!(destination.source, RoleUse::Implicit);
        assert_eq!(destination.destination, RoleUse::Explicit);

        let both = plan("join-pane -s alpha:0.0 -t beta:0.0").expect("join plan");
        assert!(!both.uses_implicit_role());
    }

    #[test]
    fn target_client_roles_do_not_consume_the_lifecycle_target() {
        let send = plan("send-keys -c client C-a").expect("send-keys plan");
        assert_eq!(send.primary, RoleUse::Unused);
        assert!(!send.uses_implicit_role());

        let display = plan("display-message -c client hello").expect("display plan");
        assert_eq!(display.primary, RoleUse::Unused);
        assert!(!display.uses_implicit_role());
    }
}