Skip to main content

git_paw/
tmux.rs

1//! Tmux session and pane orchestration.
2//!
3//! Checks tmux availability, creates sessions, splits panes, sends commands,
4//! applies layouts, and manages attach/reattach. Uses a builder pattern for
5//! testability and dry-run support.
6
7use std::process::Command;
8
9use crate::error::PawError;
10
11/// Maximum number of session name collision retries.
12const MAX_COLLISION_RETRIES: u32 = 10;
13
14/// A single tmux CLI invocation, stored as its argument list.
15///
16/// Can be inspected as a string (for dry-run / testing) or executed.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TmuxCommand {
19    args: Vec<String>,
20}
21
22impl TmuxCommand {
23    /// Create a new tmux command from the given arguments.
24    fn new(args: &[&str]) -> Self {
25        Self {
26            args: args.iter().map(|&s| s.to_owned()).collect(),
27        }
28    }
29
30    /// Return a human-readable command string (e.g. `tmux new-session -d -s paw-proj`).
31    // Not called by production code — used by `TmuxSession::command_strings()` for
32    // dry-run contract tests that verify the commands shown to users via `--dry-run`.
33    #[allow(dead_code)]
34    pub fn as_command_string(&self) -> String {
35        format!("tmux {}", self.args.join(" "))
36    }
37
38    /// Execute the command against the live tmux server.
39    fn execute(&self) -> Result<String, PawError> {
40        let output = Command::new("tmux")
41            .args(&self.args)
42            .output()
43            .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
44
45        if output.status.success() {
46            String::from_utf8(output.stdout)
47                .map_err(|e| PawError::TmuxError(format!("invalid utf-8 in tmux output: {e}")))
48        } else {
49            let stderr = String::from_utf8_lossy(&output.stderr);
50            Err(PawError::TmuxError(stderr.trim().to_owned()))
51        }
52    }
53}
54
55/// Specification for a single pane: which branch/worktree to `cd` into and which CLI to run.
56#[derive(Debug, Clone)]
57pub struct PaneSpec {
58    /// Branch name (e.g. `feat/auth`). Used for the pane title.
59    pub branch: String,
60    /// Absolute path to the git worktree directory.
61    pub worktree: String,
62    /// The CLI command to execute inside the pane.
63    pub cli_command: String,
64}
65
66/// A fully-resolved tmux session ready to execute or inspect.
67#[derive(Debug)]
68pub struct TmuxSession {
69    /// The resolved session name (e.g. `paw-myproject` or `paw-myproject-2`).
70    pub name: String,
71    commands: Vec<TmuxCommand>,
72}
73
74impl TmuxSession {
75    /// Execute all accumulated tmux commands against the live tmux server.
76    pub fn execute(&self) -> Result<(), PawError> {
77        for cmd in &self.commands {
78            cmd.execute()?;
79        }
80        Ok(())
81    }
82
83    /// Return all commands as human-readable strings (for dry-run / testing).
84    // Not called by production code — used by unit tests as the dry-run contract
85    // surface to verify the tmux commands shown to users via `--dry-run`.
86    #[allow(dead_code)]
87    pub fn command_strings(&self) -> Vec<String> {
88        self.commands
89            .iter()
90            .map(TmuxCommand::as_command_string)
91            .collect()
92    }
93}
94
95/// Builder that accumulates tmux operations for creating and configuring a session.
96///
97/// Can either execute operations against a live tmux server or return them
98/// as command strings for testing and dry-run.
99///
100/// # Examples
101///
102/// ```no_run
103/// use git_paw::tmux::{TmuxSessionBuilder, PaneSpec};
104///
105/// let session = TmuxSessionBuilder::new("my-project")
106///     .add_pane(PaneSpec {
107///         branch: "feat/auth".into(),
108///         worktree: "/tmp/my-project-feat-auth".into(),
109///         cli_command: "claude".into(),
110///     })
111///     .mouse_mode(true)
112///     .build()?;
113///
114/// // Dry-run: inspect commands
115/// for cmd in session.command_strings() {
116///     println!("{cmd}");
117/// }
118///
119/// // Or execute for real
120/// session.execute()?;
121/// # Ok::<(), git_paw::error::PawError>(())
122/// ```
123#[derive(Debug)]
124pub struct TmuxSessionBuilder {
125    project_name: String,
126    panes: Vec<PaneSpec>,
127    mouse_mode: bool,
128    session_name_override: Option<String>,
129}
130
131impl TmuxSessionBuilder {
132    /// Create a new builder for the given project name.
133    ///
134    /// The session will be named `paw-<project_name>` unless overridden
135    /// with [`session_name`](Self::session_name).
136    pub fn new(project_name: &str) -> Self {
137        Self {
138            project_name: project_name.to_owned(),
139            panes: Vec::new(),
140            mouse_mode: true,
141            session_name_override: None,
142        }
143    }
144
145    /// Override the session name instead of deriving it from the project name.
146    ///
147    /// Use this with [`resolve_session_name`] to handle name collisions.
148    #[must_use]
149    pub fn session_name(mut self, name: String) -> Self {
150        self.session_name_override = Some(name);
151        self
152    }
153
154    /// Add a pane that will `cd` into the worktree and run the CLI command.
155    #[must_use]
156    pub fn add_pane(mut self, spec: PaneSpec) -> Self {
157        self.panes.push(spec);
158        self
159    }
160
161    /// Enable or disable mouse mode for the session (default: `true`).
162    ///
163    /// When enabled, users can click to switch panes, drag borders to resize,
164    /// and scroll. This is set per-session and does not affect other tmux sessions.
165    #[must_use]
166    pub fn mouse_mode(mut self, enabled: bool) -> Self {
167        self.mouse_mode = enabled;
168        self
169    }
170
171    /// Build the full sequence of tmux commands without executing anything.
172    ///
173    /// Returns a [`TmuxSession`] that can be executed or inspected.
174    /// Returns an error if no panes have been added.
175    pub fn build(self) -> Result<TmuxSession, PawError> {
176        if self.panes.is_empty() {
177            return Err(PawError::TmuxError(
178                "cannot create a session with no panes".to_owned(),
179            ));
180        }
181
182        let session_name = self
183            .session_name_override
184            .unwrap_or_else(|| format!("paw-{}", self.project_name));
185        let mut commands = Vec::new();
186
187        // 1. Create detached session (pane 0 is implicit)
188        commands.push(TmuxCommand::new(&[
189            "new-session",
190            "-d",
191            "-s",
192            &session_name,
193        ]));
194
195        // 2. Mouse mode
196        if self.mouse_mode {
197            commands.push(TmuxCommand::new(&[
198                "set-option",
199                "-t",
200                &session_name,
201                "mouse",
202                "on",
203            ]));
204        }
205
206        // 3. Pane border titles — show branch/CLI in each pane's border
207        commands.push(TmuxCommand::new(&[
208            "set-option",
209            "-t",
210            &session_name,
211            "pane-border-status",
212            "top",
213        ]));
214        commands.push(TmuxCommand::new(&[
215            "set-option",
216            "-t",
217            &session_name,
218            "pane-border-format",
219            " #{pane_title} ",
220        ]));
221
222        // 4. First pane — already exists as pane 0
223        let first = &self.panes[0];
224        let pane_target = format!("{session_name}:0.0");
225        let pane_title = format!("{} \u{2192} {}", first.branch, first.cli_command);
226        let pane_cmd = format!("cd {} && {}", first.worktree, first.cli_command);
227        commands.push(TmuxCommand::new(&[
228            "select-pane",
229            "-t",
230            &pane_target,
231            "-T",
232            &pane_title,
233        ]));
234        commands.push(TmuxCommand::new(&[
235            "send-keys",
236            "-t",
237            &pane_target,
238            &pane_cmd,
239            "Enter",
240        ]));
241
242        // 5. Subsequent panes — tiled layout before each split
243        for (i, pane) in self.panes.iter().enumerate().skip(1) {
244            // Apply tiled layout before split to ensure space
245            commands.push(TmuxCommand::new(&[
246                "select-layout",
247                "-t",
248                &session_name,
249                "tiled",
250            ]));
251
252            // Split window to create new pane
253            commands.push(TmuxCommand::new(&["split-window", "-t", &session_name]));
254
255            // Title and command for the new pane
256            let pane_target = format!("{session_name}:0.{i}");
257            let pane_title = format!("{} \u{2192} {}", pane.branch, pane.cli_command);
258            let pane_cmd = format!("cd {} && {}", pane.worktree, pane.cli_command);
259            commands.push(TmuxCommand::new(&[
260                "select-pane",
261                "-t",
262                &pane_target,
263                "-T",
264                &pane_title,
265            ]));
266            commands.push(TmuxCommand::new(&[
267                "send-keys",
268                "-t",
269                &pane_target,
270                &pane_cmd,
271                "Enter",
272            ]));
273        }
274
275        // 6. Final tiled layout for clean alignment
276        commands.push(TmuxCommand::new(&[
277            "select-layout",
278            "-t",
279            &session_name,
280            "tiled",
281        ]));
282
283        Ok(TmuxSession {
284            name: session_name,
285            commands,
286        })
287    }
288}
289
290/// Check that tmux is installed on PATH.
291///
292/// Returns `Ok(())` if found, or `Err(PawError::TmuxNotInstalled)` with
293/// install instructions if missing.
294pub fn ensure_tmux_installed() -> Result<(), PawError> {
295    which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
296    Ok(())
297}
298
299/// Check whether a tmux session with the given name is currently alive.
300pub fn is_session_alive(name: &str) -> Result<bool, PawError> {
301    let status = Command::new("tmux")
302        .args(["has-session", "-t", name])
303        .stdout(std::process::Stdio::null())
304        .stderr(std::process::Stdio::null())
305        .status()
306        .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
307
308    Ok(status.success())
309}
310
311/// Resolve a unique session name, handling collisions with existing sessions.
312///
313/// Starts with `paw-<project_name>` and appends `-2`, `-3`, etc. if the name
314/// is already taken by another session.
315pub fn resolve_session_name(project_name: &str) -> Result<String, PawError> {
316    let base = format!("paw-{project_name}");
317
318    if !is_session_alive(&base)? {
319        return Ok(base);
320    }
321
322    for suffix in 2..=MAX_COLLISION_RETRIES + 1 {
323        let candidate = format!("{base}-{suffix}");
324        if !is_session_alive(&candidate)? {
325            return Ok(candidate);
326        }
327    }
328
329    Err(PawError::TmuxError(format!(
330        "too many session name collisions for '{base}'"
331    )))
332}
333
334/// Attach the current terminal to the named tmux session.
335///
336/// This replaces the current process's stdio. Returns an error if the
337/// session does not exist or tmux fails.
338pub fn attach(name: &str) -> Result<(), PawError> {
339    let status = Command::new("tmux")
340        .args(["attach-session", "-t", name])
341        .status()
342        .map_err(|e| PawError::TmuxError(format!("failed to attach to tmux session: {e}")))?;
343
344    if status.success() {
345        Ok(())
346    } else {
347        Err(PawError::TmuxError(format!(
348            "failed to attach to session '{name}'"
349        )))
350    }
351}
352
353/// Kill the named tmux session.
354pub fn kill_session(name: &str) -> Result<(), PawError> {
355    let output = Command::new("tmux")
356        .args(["kill-session", "-t", name])
357        .output()
358        .map_err(|e| PawError::TmuxError(format!("failed to kill tmux session: {e}")))?;
359
360    if output.status.success() {
361        Ok(())
362    } else {
363        let stderr = String::from_utf8_lossy(&output.stderr);
364        Err(PawError::TmuxError(stderr.trim().to_owned()))
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    fn make_pane(branch: &str, worktree: &str, cli: &str) -> PaneSpec {
373        PaneSpec {
374            branch: branch.to_owned(),
375            worktree: worktree.to_owned(),
376            cli_command: cli.to_owned(),
377        }
378    }
379
380    /// Helper: extract command strings matching a keyword from a session's commands.
381    fn commands_containing(cmds: &[String], keyword: &str) -> Vec<String> {
382        cmds.iter()
383            .filter(|c| c.contains(keyword))
384            .cloned()
385            .collect()
386    }
387
388    // -----------------------------------------------------------------------
389    // AC: Checks tmux presence with actionable error
390    // Behavioral: verifies the public contract — does the system detect tmux?
391    // -----------------------------------------------------------------------
392
393    #[test]
394    #[serial_test::serial]
395    fn ensure_tmux_installed_succeeds_when_present() {
396        // Requires #[serial] because detect tests modify PATH.
397        assert!(ensure_tmux_installed().is_ok());
398    }
399
400    // -----------------------------------------------------------------------
401    // AC: Creates named sessions, handles collision
402    // Behavioral: session name is a public field used by attach, status, and
403    // dry-run output. The exact naming convention is the public contract.
404    // -----------------------------------------------------------------------
405
406    #[test]
407    fn session_is_named_after_project() {
408        let session = TmuxSessionBuilder::new("my-project")
409            .add_pane(make_pane("main", "/tmp/wt", "claude"))
410            .build()
411            .unwrap();
412
413        assert_eq!(session.name, "paw-my-project");
414    }
415
416    #[test]
417    fn session_creation_command_uses_session_name() {
418        let session = TmuxSessionBuilder::new("app")
419            .add_pane(make_pane("main", "/tmp/wt", "claude"))
420            .build()
421            .unwrap();
422
423        let cmds = session.command_strings();
424        assert!(
425            cmds.iter()
426                .any(|c| c.contains("new-session") && c.contains("paw-app")),
427            "should create a tmux session named paw-app"
428        );
429    }
430
431    #[test]
432    fn session_name_override_replaces_default() {
433        let session = TmuxSessionBuilder::new("my-project")
434            .session_name("custom-session-name".to_string())
435            .add_pane(make_pane("main", "/tmp/wt", "claude"))
436            .build()
437            .unwrap();
438
439        assert_eq!(session.name, "custom-session-name");
440        let cmds = session.command_strings();
441        assert!(
442            cmds.iter()
443                .any(|c| c.contains("new-session") && c.contains("custom-session-name")),
444            "should use overridden session name"
445        );
446    }
447
448    // -----------------------------------------------------------------------
449    // AC: Dynamic pane count based on input
450    // Dry-run contract: verifies the number of commands matches the number of
451    // panes the user requested. Actual pane creation verified by e2e test
452    // tmux_session_with_five_panes_and_different_clis.
453    // -----------------------------------------------------------------------
454
455    #[test]
456    fn pane_count_matches_input_for_two_panes() {
457        let session = TmuxSessionBuilder::new("proj")
458            .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
459            .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
460            .build()
461            .unwrap();
462
463        let cmds = session.command_strings();
464        let send_keys = commands_containing(&cmds, "send-keys");
465        assert_eq!(
466            send_keys.len(),
467            2,
468            "should send commands to exactly 2 panes"
469        );
470    }
471
472    #[test]
473    fn pane_count_matches_input_for_five_panes() {
474        let mut builder = TmuxSessionBuilder::new("proj");
475        for i in 0..5 {
476            builder = builder.add_pane(make_pane(
477                &format!("feat/b{i}"),
478                &format!("/tmp/wt{i}"),
479                "claude",
480            ));
481        }
482        let session = builder.build().unwrap();
483
484        let cmds = session.command_strings();
485        let send_keys = commands_containing(&cmds, "send-keys");
486        assert_eq!(
487            send_keys.len(),
488            5,
489            "should send commands to exactly 5 panes"
490        );
491    }
492
493    #[test]
494    fn building_with_no_panes_is_an_error() {
495        let result = TmuxSessionBuilder::new("proj").build();
496        assert!(result.is_err(), "session with no panes should fail");
497    }
498
499    // -----------------------------------------------------------------------
500    // AC: Correct commands sent to panes
501    // Dry-run contract: users see these exact commands in --dry-run output,
502    // so the format (cd + cli, Enter, pane indices) is user-facing.
503    // -----------------------------------------------------------------------
504
505    #[test]
506    fn each_pane_receives_cd_and_cli_command() {
507        let session = TmuxSessionBuilder::new("proj")
508            .add_pane(make_pane("feat/auth", "/home/user/wt-auth", "claude"))
509            .add_pane(make_pane("feat/api", "/home/user/wt-api", "gemini"))
510            .build()
511            .unwrap();
512
513        let cmds = session.command_strings();
514        let send_keys = commands_containing(&cmds, "send-keys");
515
516        assert!(
517            send_keys[0].contains("cd /home/user/wt-auth && claude"),
518            "first pane should cd into wt-auth and run claude"
519        );
520        assert!(
521            send_keys[1].contains("cd /home/user/wt-api && gemini"),
522            "second pane should cd into wt-api and run gemini"
523        );
524    }
525
526    #[test]
527    fn pane_commands_are_submitted_with_enter() {
528        let session = TmuxSessionBuilder::new("proj")
529            .add_pane(make_pane("main", "/tmp/wt", "aider"))
530            .build()
531            .unwrap();
532
533        let cmds = session.command_strings();
534        let send_keys = commands_containing(&cmds, "send-keys");
535        assert!(
536            send_keys[0].contains("Enter"),
537            "send-keys should press Enter to submit"
538        );
539    }
540
541    #[test]
542    fn each_pane_targets_a_distinct_pane_index() {
543        let session = TmuxSessionBuilder::new("proj")
544            .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
545            .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
546            .add_pane(make_pane("feat/c", "/tmp/c", "gemini"))
547            .build()
548            .unwrap();
549
550        let cmds = session.command_strings();
551        let send_keys = commands_containing(&cmds, "send-keys");
552
553        assert!(
554            send_keys[0].contains(":0.0"),
555            "first pane should target :0.0"
556        );
557        assert!(
558            send_keys[1].contains(":0.1"),
559            "second pane should target :0.1"
560        );
561        assert!(
562            send_keys[2].contains(":0.2"),
563            "third pane should target :0.2"
564        );
565    }
566
567    // -----------------------------------------------------------------------
568    // AC: Pane titles show branch and CLI
569    // Dry-run contract: title format is user-visible in both --dry-run output
570    // and tmux pane borders. Actual tmux titles verified by e2e test
571    // tmux_session_with_five_panes_and_different_clis.
572    // -----------------------------------------------------------------------
573
574    #[test]
575    fn each_pane_is_titled_with_branch_and_cli() {
576        let session = TmuxSessionBuilder::new("proj")
577            .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
578            .add_pane(make_pane("fix/api", "/tmp/wt2", "gemini"))
579            .build()
580            .unwrap();
581
582        let cmds = session.command_strings();
583        let select_panes = commands_containing(&cmds, "select-pane");
584
585        assert_eq!(select_panes.len(), 2, "each pane should get a title");
586        assert!(
587            select_panes[0].contains("feat/auth \u{2192} claude"),
588            "first pane title should be 'feat/auth \u{2192} claude', got: {}",
589            select_panes[0]
590        );
591        assert!(
592            select_panes[1].contains("fix/api \u{2192} gemini"),
593            "second pane title should be 'fix/api \u{2192} gemini', got: {}",
594            select_panes[1]
595        );
596    }
597
598    #[test]
599    fn pane_border_status_is_configured() {
600        let session = TmuxSessionBuilder::new("proj")
601            .add_pane(make_pane("main", "/tmp/wt", "claude"))
602            .build()
603            .unwrap();
604
605        let cmds = session.command_strings();
606        assert!(
607            cmds.iter()
608                .any(|c| c.contains("pane-border-status") && c.contains("top")),
609            "should configure pane-border-status to top"
610        );
611        assert!(
612            cmds.iter()
613                .any(|c| c.contains("pane-border-format") && c.contains("#{pane_title}")),
614            "should configure pane-border-format to show pane title"
615        );
616    }
617
618    // -----------------------------------------------------------------------
619    // AC: Mouse mode (per-session, configurable, default on)
620    // Dry-run contract: users see mouse config in --dry-run output.
621    // Actual tmux behavior verified by e2e test tmux_mouse_mode_enabled_by_default.
622    // -----------------------------------------------------------------------
623
624    #[test]
625    fn mouse_mode_enabled_by_default() {
626        let session = TmuxSessionBuilder::new("proj")
627            .add_pane(make_pane("main", "/tmp/wt", "claude"))
628            .build()
629            .unwrap();
630
631        let cmds = session.command_strings();
632        assert!(
633            cmds.iter().any(|c| c.contains("mouse on")),
634            "mouse should be enabled by default"
635        );
636    }
637
638    #[test]
639    fn mouse_mode_can_be_disabled() {
640        let session = TmuxSessionBuilder::new("proj")
641            .add_pane(make_pane("main", "/tmp/wt", "claude"))
642            .mouse_mode(false)
643            .build()
644            .unwrap();
645
646        let cmds = session.command_strings();
647        assert!(
648            !cmds.iter().any(|c| c.contains("mouse on")),
649            "no mouse-on command should be emitted when disabled"
650        );
651    }
652
653    // -----------------------------------------------------------------------
654    // AC: Session liveness and collision handling
655    // Behavioral: tests against a real tmux server — verifies observable
656    // outcomes (session exists, session is killed, names are unique).
657    // -----------------------------------------------------------------------
658
659    /// Helper to create a detached tmux session for testing.
660    fn create_test_session(name: &str) {
661        let output = std::process::Command::new("tmux")
662            .args(["new-session", "-d", "-s", name])
663            .output()
664            .expect("create tmux session");
665        assert!(
666            output.status.success(),
667            "failed to create test session '{name}'"
668        );
669    }
670
671    /// Helper to kill a tmux session, ignoring errors.
672    fn cleanup_session(name: &str) {
673        let _ = kill_session(name);
674    }
675
676    #[test]
677    #[serial_test::serial]
678    fn is_session_alive_returns_false_for_nonexistent() {
679        let alive = is_session_alive("paw-definitely-does-not-exist-12345").unwrap();
680        assert!(!alive);
681    }
682
683    #[test]
684    #[serial_test::serial]
685    fn session_lifecycle_create_check_kill() {
686        let name = "paw-unit-test-lifecycle";
687        cleanup_session(name);
688
689        create_test_session(name);
690        assert!(is_session_alive(name).unwrap());
691
692        kill_session(name).unwrap();
693        assert!(!is_session_alive(name).unwrap());
694    }
695
696    #[test]
697    #[serial_test::serial]
698    fn resolve_session_name_returns_base_when_no_collision() {
699        let name = resolve_session_name("unit-test-no-collision-xyz").unwrap();
700        assert_eq!(name, "paw-unit-test-no-collision-xyz");
701    }
702
703    #[test]
704    #[serial_test::serial]
705    fn resolve_session_name_appends_suffix_on_collision() {
706        let base_name = "paw-unit-test-collision";
707        cleanup_session(base_name);
708        cleanup_session(&format!("{base_name}-2"));
709
710        create_test_session(base_name);
711
712        let resolved = resolve_session_name("unit-test-collision").unwrap();
713        assert_eq!(resolved, format!("{base_name}-2"));
714
715        cleanup_session(base_name);
716    }
717
718    #[test]
719    #[serial_test::serial]
720    fn built_session_can_be_executed_and_killed() {
721        let project = "unit-test-execute";
722        let session_name = format!("paw-{project}");
723        cleanup_session(&session_name);
724
725        let session = TmuxSessionBuilder::new(project)
726            .add_pane(make_pane("main", "/tmp", "echo hello"))
727            .build()
728            .unwrap();
729
730        session.execute().unwrap();
731        assert!(is_session_alive(&session_name).unwrap());
732
733        kill_session(&session_name).unwrap();
734        assert!(!is_session_alive(&session_name).unwrap());
735    }
736}