trusty-mpm 0.19.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
//! Real tmux driver adapter bridging [`ManagedTmuxDriver`] to [`TmuxDriver`].
//!
//! Why: the session manager speaks to tmux through the [`ManagedTmuxDriver`]
//! trait seam (so it stays unit-testable), but the daemon already owns a
//! concrete [`crate::daemon::tmux::TmuxDriver`]. This adapter bridges the two so
//! the daemon can hand the session manager a real, tmux-backed driver without
//! duplicating any `tmux` subprocess logic.
//! What: [`RealTmuxDriver`] wraps a `TmuxDriver` and maps each trait method onto
//! the corresponding `TmuxDriver` call, translating its `anyhow`/typed errors
//! into [`ManagedError`].
//! Test: `real_tmux_constructs` (construction is the only side-effect-free path;
//! the actual tmux calls require a live `tmux` binary and are exercised by the
//! `#[ignore]` live integration test in `tests/session_manager_mvp.rs`).

use crate::core::tmux::TmuxTarget;
use crate::daemon::tmux::TmuxDriver;

use super::manager::{ManagedError, ManagedTmuxDriver};

/// Adapter exposing the daemon's [`TmuxDriver`] as a [`ManagedTmuxDriver`].
///
/// Why: the session manager depends on the trait, not the concrete driver, so a
/// thin adapter keeps the manager runtime-agnostic while reusing the daemon's
/// existing tmux subprocess wrapper.
/// What: holds a `TmuxDriver` and forwards every trait method to it, adapting
/// argument and error types.
/// Test: `real_tmux_constructs`.
pub struct RealTmuxDriver {
    driver: TmuxDriver,
}

impl RealTmuxDriver {
    /// Construct a real tmux driver by discovering the `tmux` binary.
    ///
    /// Why: the daemon needs a ready-to-use driver; discovery resolves the
    /// `tmux` path once so subsequent calls avoid repeated PATH lookups.
    /// What: calls [`TmuxDriver::discover`], mapping failure into
    /// [`ManagedError::TmuxUnavailable`].
    /// Test: `real_tmux_constructs` (succeeds only when tmux is installed).
    pub fn discover() -> Result<Self, ManagedError> {
        let driver =
            TmuxDriver::discover().map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))?;
        Ok(Self { driver })
    }
}

impl ManagedTmuxDriver for RealTmuxDriver {
    fn create_session(&self, name: &str, workdir: &str) -> Result<(), ManagedError> {
        self.driver
            .create_session(name, Some(workdir))
            .map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
    }

    fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
        self.driver
            .kill_session(name)
            .map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
    }

    fn send_line(&self, name: &str, text: &str) -> Result<(), ManagedError> {
        self.driver
            .send_line(&TmuxTarget::session(name), text)
            .map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
    }

    /// Type literal text with NO trailing Enter (overrides the trait default so
    /// the production path actually omits the newline; #1461).
    fn send_keys_literal(&self, name: &str, text: &str) -> Result<(), ManagedError> {
        self.driver
            .send_keys_literal(&TmuxTarget::session(name), text)
            .map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
    }

    /// Send Ctrl-C to the session (overrides the no-op trait default; #1461).
    fn send_interrupt(&self, name: &str) -> Result<(), ManagedError> {
        self.driver
            .send_interrupt(&TmuxTarget::session(name))
            .map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
    }

    fn capture(&self, name: &str, lines: usize) -> Result<String, ManagedError> {
        // The tmux `-S -<n>` argv takes a u32; this driver edge is the single
        // narrowing point so the rest of the observe path stays `usize`. An
        // out-of-range request is saturated to u32::MAX (capturing the whole
        // scrollback) rather than erroring — more pane than asked for is benign.
        let lines = u32::try_from(lines).unwrap_or(u32::MAX);
        self.driver
            .capture(&TmuxTarget::session(name), Some(lines))
            .map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
    }

    fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
        self.driver
            .list_sessions()
            .map(|sessions| sessions.into_iter().map(|s| s.name).collect())
            .map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
    }

    fn get_pane_cwd(&self, name: &str) -> Option<std::path::PathBuf> {
        self.driver.pane_current_path(name)
    }

    /// Overrides the no-op trait default so the production path actually calls
    /// `tmux set-environment` (#2157 item 1).
    fn set_environment(&self, name: &str, key: &str, value: &str) -> Result<(), ManagedError> {
        self.driver
            .set_environment(name, key, value)
            .map_err(|e| ManagedError::TmuxUnavailable(e.to_string()))
    }
}

/// A no-op tmux driver used when `tmux` is not installed.
///
/// Why: the managed-session API must still respond (list/get/spawn-record) even
/// on a host without `tmux`; operations that genuinely need a pane return a
/// typed error rather than panicking, and read-only operations keep working.
/// What: every method that mutates tmux state returns
/// [`ManagedError::TmuxUnavailable`]; `list_sessions` returns an empty list so
/// reconciliation treats every stored session as orphaned.
/// Test: side-effect-only fallback; covered indirectly by the daemon's
/// `session_manager` accessor when tmux is absent.
pub struct NoopTmuxDriver;

impl ManagedTmuxDriver for NoopTmuxDriver {
    fn create_session(&self, _name: &str, _workdir: &str) -> Result<(), ManagedError> {
        Err(ManagedError::TmuxUnavailable("tmux not installed".into()))
    }

    fn kill_session(&self, _name: &str) -> Result<(), ManagedError> {
        Ok(())
    }

    fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
        Err(ManagedError::TmuxUnavailable("tmux not installed".into()))
    }

    fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
        Ok(String::new())
    }

    fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
        Ok(Vec::new())
    }

    /// Fails loudly like every other mutating op on this driver — mirrors
    /// `create_session`/`send_line` rather than the trait's silent-no-op
    /// default, keeping "tmux is absent" uniformly observable.
    fn set_environment(&self, _name: &str, _key: &str, _value: &str) -> Result<(), ManagedError> {
        Err(ManagedError::TmuxUnavailable("tmux not installed".into()))
    }
}

/// A silent no-op tmux driver used in tests that need to create session records
/// without spawning any real tmux process.
///
/// Why: tests that exercise session-manager business logic (lifecycle transitions,
/// route handlers, prune/decommission) must not spawn real `tmpm-*` tmux
/// sessions. Real sessions created by tests escape into the host and get adopted
/// by the production daemon's `reconcile_on_boot`, polluting `~/.trusty-mpm`
/// with phantom sessions (issue #1790). `NoopTmuxDriver` is the production
/// "tmux unavailable" fallback and intentionally returns `Err` on `create_session`
/// — the wrong behavior for tests that call `create_with_id`. This driver instead
/// accepts every mutating call as a silent no-op so session records can be seeded
/// freely without touching real tmux.
/// What: `create_session` and `kill_session` return `Ok(())` silently; `send_line`
/// returns `Ok(())` silently; `capture` returns an empty string; `list_sessions`
/// returns an empty list so `reconcile_on_boot` sees no live sessions and marks
/// stored records `Stopped` (never adopts real host sessions).
/// Test: used by [`DaemonState::with_root_isolated_managed`] — the sole test
/// constructor that pre-seeds the `managed_sessions` OnceCell so the lazy
/// initialiser never touches the real tmux binary.
///
/// Note: compiled unconditionally under the `daemon` feature (not `#[cfg(test)]`)
/// so integration tests in `tests/` can reference it; never called in production.
#[doc(hidden)]
pub struct FakeNoopTmuxDriver;

impl ManagedTmuxDriver for FakeNoopTmuxDriver {
    fn create_session(&self, _name: &str, _workdir: &str) -> Result<(), ManagedError> {
        Ok(())
    }

    fn kill_session(&self, _name: &str) -> Result<(), ManagedError> {
        Ok(())
    }

    fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
        Ok(())
    }

    fn send_keys_literal(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
        Ok(())
    }

    fn send_interrupt(&self, _name: &str) -> Result<(), ManagedError> {
        Ok(())
    }

    fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
        Ok(String::new())
    }

    fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
        Ok(Vec::new())
    }
}

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

    #[test]
    fn real_tmux_constructs() {
        // Construction only succeeds when `tmux` is on PATH; in CI it may not be,
        // so we only assert the call does not panic and returns a typed result.
        let _ = RealTmuxDriver::discover();
    }

    #[test]
    fn fake_noop_driver_accepts_create_session() {
        // Why: regression guard that FakeNoopTmuxDriver::create_session returns
        // Ok so test code that seeds session records never hits TmuxUnavailable.
        let driver = FakeNoopTmuxDriver;
        assert!(
            driver.create_session("tmpm-test-abc123", "/tmp").is_ok(),
            "FakeNoopTmuxDriver::create_session must succeed"
        );
    }

    #[test]
    fn noop_driver_set_environment_fails_loudly() {
        // #2157: the no-tmux fallback must fail this best-effort mutating op the
        // same way it fails create_session/send_line, not silently no-op.
        let driver = NoopTmuxDriver;
        assert!(driver.set_environment("s", "K", "v").is_err());
    }

    #[test]
    fn fake_noop_driver_list_sessions_is_empty() {
        // Why: list_sessions must return empty so reconcile_on_boot never adopts
        // real host sessions into the test-owned store.
        let driver = FakeNoopTmuxDriver;
        let sessions = driver.list_sessions().expect("list_sessions ok");
        assert!(
            sessions.is_empty(),
            "FakeNoopTmuxDriver must see no live sessions"
        );
    }
}