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    /// Queue a `pipe-pane` command to capture pane output to a log file.
95    ///
96    /// Appends `tmux pipe-pane -o -t <pane_target> "cat >> <log_path>"` to the
97    /// command queue. Should be called after the pane has been created.
98    pub fn pipe_pane(&mut self, pane_target: &str, log_path: &std::path::Path) -> &mut Self {
99        self.commands.push(TmuxCommand::new(&[
100            "pipe-pane",
101            "-o",
102            "-t",
103            pane_target,
104            &format!("cat >> {}", log_path.display()),
105        ]));
106        self
107    }
108}
109
110/// Builder that accumulates tmux operations for creating and configuring a session.
111///
112/// Can either execute operations against a live tmux server or return them
113/// as command strings for testing and dry-run.
114///
115/// # Examples
116///
117/// ```no_run
118/// use git_paw::tmux::{TmuxSessionBuilder, PaneSpec};
119///
120/// let session = TmuxSessionBuilder::new("my-project")
121///     .add_pane(PaneSpec {
122///         branch: "feat/auth".into(),
123///         worktree: "/tmp/my-project-feat-auth".into(),
124///         cli_command: "claude".into(),
125///     })
126///     .mouse_mode(true)
127///     .build()?;
128///
129/// // Dry-run: inspect commands
130/// for cmd in session.command_strings() {
131///     println!("{cmd}");
132/// }
133///
134/// // Or execute for real
135/// session.execute()?;
136/// # Ok::<(), git_paw::error::PawError>(())
137/// ```
138#[derive(Debug)]
139pub struct TmuxSessionBuilder {
140    project_name: String,
141    panes: Vec<PaneSpec>,
142    mouse_mode: bool,
143    session_name_override: Option<String>,
144}
145
146impl TmuxSessionBuilder {
147    /// Create a new builder for the given project name.
148    ///
149    /// The session will be named `paw-<project_name>` unless overridden
150    /// with [`session_name`](Self::session_name).
151    pub fn new(project_name: &str) -> Self {
152        Self {
153            project_name: project_name.to_owned(),
154            panes: Vec::new(),
155            mouse_mode: true,
156            session_name_override: None,
157        }
158    }
159
160    /// Override the session name instead of deriving it from the project name.
161    ///
162    /// Use this with [`resolve_session_name`] to handle name collisions.
163    #[must_use]
164    pub fn session_name(mut self, name: String) -> Self {
165        self.session_name_override = Some(name);
166        self
167    }
168
169    /// Add a pane that will `cd` into the worktree and run the CLI command.
170    #[must_use]
171    pub fn add_pane(mut self, spec: PaneSpec) -> Self {
172        self.panes.push(spec);
173        self
174    }
175
176    /// Enable or disable mouse mode for the session (default: `true`).
177    ///
178    /// When enabled, users can click to switch panes, drag borders to resize,
179    /// and scroll. This is set per-session and does not affect other tmux sessions.
180    #[must_use]
181    pub fn mouse_mode(mut self, enabled: bool) -> Self {
182        self.mouse_mode = enabled;
183        self
184    }
185
186    /// Build the full sequence of tmux commands without executing anything.
187    ///
188    /// Returns a [`TmuxSession`] that can be executed or inspected.
189    /// Returns an error if no panes have been added.
190    pub fn build(self) -> Result<TmuxSession, PawError> {
191        if self.panes.is_empty() {
192            return Err(PawError::TmuxError(
193                "cannot create a session with no panes".to_owned(),
194            ));
195        }
196
197        let session_name = self
198            .session_name_override
199            .unwrap_or_else(|| format!("paw-{}", self.project_name));
200        let mut commands = Vec::new();
201
202        // 1. Create detached session (pane 0 is implicit)
203        // Use -c to set pane 0's working directory directly, avoiding a race
204        // condition where send-keys fires before the shell is ready.
205        let first_worktree = &self.panes[0].worktree;
206        commands.push(TmuxCommand::new(&[
207            "new-session",
208            "-d",
209            "-s",
210            &session_name,
211            "-c",
212            first_worktree,
213        ]));
214
215        // 2. Mouse mode
216        if self.mouse_mode {
217            commands.push(TmuxCommand::new(&[
218                "set-option",
219                "-t",
220                &session_name,
221                "mouse",
222                "on",
223            ]));
224        }
225
226        // 3. Pane border titles — show branch/CLI in each pane's border
227        commands.push(TmuxCommand::new(&[
228            "set-option",
229            "-t",
230            &session_name,
231            "pane-border-status",
232            "top",
233        ]));
234        commands.push(TmuxCommand::new(&[
235            "set-option",
236            "-t",
237            &session_name,
238            "pane-border-format",
239            " #{pane_title} ",
240        ]));
241
242        // 4. First pane — already exists as pane 0 (directory set by -c above)
243        let first = &self.panes[0];
244        let pane_target = format!("{session_name}:0.0");
245        let pane_title = format!("{} \u{2192} {}", first.branch, first.cli_command);
246        commands.push(TmuxCommand::new(&[
247            "select-pane",
248            "-t",
249            &pane_target,
250            "-T",
251            &pane_title,
252        ]));
253        commands.push(TmuxCommand::new(&[
254            "send-keys",
255            "-t",
256            &pane_target,
257            &first.cli_command,
258            "Enter",
259        ]));
260
261        // 5. Subsequent panes — tiled layout before each split
262        for (i, pane) in self.panes.iter().enumerate().skip(1) {
263            // Apply tiled layout before split to ensure space
264            commands.push(TmuxCommand::new(&[
265                "select-layout",
266                "-t",
267                &session_name,
268                "tiled",
269            ]));
270
271            // Split window to create new pane
272            commands.push(TmuxCommand::new(&["split-window", "-t", &session_name]));
273
274            // Title and command for the new pane
275            let pane_target = format!("{session_name}:0.{i}");
276            let pane_title = format!("{} \u{2192} {}", pane.branch, pane.cli_command);
277            let pane_cmd = format!("cd {} && {}", pane.worktree, pane.cli_command);
278            commands.push(TmuxCommand::new(&[
279                "select-pane",
280                "-t",
281                &pane_target,
282                "-T",
283                &pane_title,
284            ]));
285            commands.push(TmuxCommand::new(&[
286                "send-keys",
287                "-t",
288                &pane_target,
289                &pane_cmd,
290                "Enter",
291            ]));
292        }
293
294        // 6. Final tiled layout for clean alignment
295        commands.push(TmuxCommand::new(&[
296            "select-layout",
297            "-t",
298            &session_name,
299            "tiled",
300        ]));
301
302        Ok(TmuxSession {
303            name: session_name,
304            commands,
305        })
306    }
307}
308
309/// Check that tmux is installed on PATH.
310///
311/// Returns `Ok(())` if found, or `Err(PawError::TmuxNotInstalled)` with
312/// install instructions if missing.
313pub fn ensure_tmux_installed() -> Result<(), PawError> {
314    which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
315    Ok(())
316}
317
318/// Check whether a tmux session with the given name is currently alive.
319pub fn is_session_alive(name: &str) -> Result<bool, PawError> {
320    let status = Command::new("tmux")
321        .args(["has-session", "-t", name])
322        .stdout(std::process::Stdio::null())
323        .stderr(std::process::Stdio::null())
324        .status()
325        .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
326
327    Ok(status.success())
328}
329
330/// Resolve a unique session name, handling collisions with existing sessions.
331///
332/// Starts with `paw-<project_name>` and appends `-2`, `-3`, etc. if the name
333/// is already taken by another session.
334pub fn resolve_session_name(project_name: &str) -> Result<String, PawError> {
335    let base = format!("paw-{project_name}");
336
337    if !is_session_alive(&base)? {
338        return Ok(base);
339    }
340
341    for suffix in 2..=MAX_COLLISION_RETRIES + 1 {
342        let candidate = format!("{base}-{suffix}");
343        if !is_session_alive(&candidate)? {
344            return Ok(candidate);
345        }
346    }
347
348    Err(PawError::TmuxError(format!(
349        "too many session name collisions for '{base}'"
350    )))
351}
352
353/// Attach the current terminal to the named tmux session.
354///
355/// This replaces the current process's stdio. Returns an error if the
356/// session does not exist or tmux fails.
357pub fn attach(name: &str) -> Result<(), PawError> {
358    let status = Command::new("tmux")
359        .args(["attach-session", "-t", name])
360        .status()
361        .map_err(|e| PawError::TmuxError(format!("failed to attach to tmux session: {e}")))?;
362
363    if status.success() {
364        Ok(())
365    } else {
366        Err(PawError::TmuxError(format!(
367            "failed to attach to session '{name}'"
368        )))
369    }
370}
371
372/// Kill the named tmux session.
373pub fn kill_session(name: &str) -> Result<(), PawError> {
374    let output = Command::new("tmux")
375        .args(["kill-session", "-t", name])
376        .output()
377        .map_err(|e| PawError::TmuxError(format!("failed to kill tmux session: {e}")))?;
378
379    if output.status.success() {
380        Ok(())
381    } else {
382        let stderr = String::from_utf8_lossy(&output.stderr);
383        Err(PawError::TmuxError(stderr.trim().to_owned()))
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn make_pane(branch: &str, worktree: &str, cli: &str) -> PaneSpec {
392        PaneSpec {
393            branch: branch.to_owned(),
394            worktree: worktree.to_owned(),
395            cli_command: cli.to_owned(),
396        }
397    }
398
399    /// Helper: extract command strings matching a keyword from a session's commands.
400    fn commands_containing(cmds: &[String], keyword: &str) -> Vec<String> {
401        cmds.iter()
402            .filter(|c| c.contains(keyword))
403            .cloned()
404            .collect()
405    }
406
407    // -----------------------------------------------------------------------
408    // AC: Checks tmux presence with actionable error
409    // Behavioral: verifies the public contract — does the system detect tmux?
410    // -----------------------------------------------------------------------
411
412    #[test]
413    #[serial_test::serial]
414    fn ensure_tmux_installed_succeeds_when_present() {
415        // Requires #[serial] because detect tests modify PATH.
416        assert!(ensure_tmux_installed().is_ok());
417    }
418
419    // -----------------------------------------------------------------------
420    // AC: Creates named sessions, handles collision
421    // Behavioral: session name is a public field used by attach, status, and
422    // dry-run output. The exact naming convention is the public contract.
423    // -----------------------------------------------------------------------
424
425    #[test]
426    fn session_is_named_after_project() {
427        let session = TmuxSessionBuilder::new("my-project")
428            .add_pane(make_pane("main", "/tmp/wt", "claude"))
429            .build()
430            .unwrap();
431
432        assert_eq!(session.name, "paw-my-project");
433    }
434
435    #[test]
436    fn session_creation_command_uses_session_name() {
437        let session = TmuxSessionBuilder::new("app")
438            .add_pane(make_pane("main", "/tmp/wt", "claude"))
439            .build()
440            .unwrap();
441
442        let cmds = session.command_strings();
443        assert!(
444            cmds.iter()
445                .any(|c| c.contains("new-session") && c.contains("paw-app")),
446            "should create a tmux session named paw-app"
447        );
448    }
449
450    #[test]
451    fn session_name_override_replaces_default() {
452        let session = TmuxSessionBuilder::new("my-project")
453            .session_name("custom-session-name".to_string())
454            .add_pane(make_pane("main", "/tmp/wt", "claude"))
455            .build()
456            .unwrap();
457
458        assert_eq!(session.name, "custom-session-name");
459        let cmds = session.command_strings();
460        assert!(
461            cmds.iter()
462                .any(|c| c.contains("new-session") && c.contains("custom-session-name")),
463            "should use overridden session name"
464        );
465    }
466
467    // -----------------------------------------------------------------------
468    // AC: Dynamic pane count based on input
469    // Dry-run contract: verifies the number of commands matches the number of
470    // panes the user requested. Actual pane creation verified by e2e test
471    // tmux_session_with_five_panes_and_different_clis.
472    // -----------------------------------------------------------------------
473
474    #[test]
475    fn pane_count_matches_input_for_two_panes() {
476        let session = TmuxSessionBuilder::new("proj")
477            .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
478            .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
479            .build()
480            .unwrap();
481
482        let cmds = session.command_strings();
483        let send_keys = commands_containing(&cmds, "send-keys");
484        assert_eq!(
485            send_keys.len(),
486            2,
487            "should send commands to exactly 2 panes"
488        );
489    }
490
491    #[test]
492    fn pane_count_matches_input_for_five_panes() {
493        let mut builder = TmuxSessionBuilder::new("proj");
494        for i in 0..5 {
495            builder = builder.add_pane(make_pane(
496                &format!("feat/b{i}"),
497                &format!("/tmp/wt{i}"),
498                "claude",
499            ));
500        }
501        let session = builder.build().unwrap();
502
503        let cmds = session.command_strings();
504        let send_keys = commands_containing(&cmds, "send-keys");
505        assert_eq!(
506            send_keys.len(),
507            5,
508            "should send commands to exactly 5 panes"
509        );
510    }
511
512    #[test]
513    fn building_with_no_panes_is_an_error() {
514        let result = TmuxSessionBuilder::new("proj").build();
515        assert!(result.is_err(), "session with no panes should fail");
516    }
517
518    // -----------------------------------------------------------------------
519    // AC: Correct commands sent to panes
520    // Dry-run contract: users see these exact commands in --dry-run output,
521    // so the format (cd + cli, Enter, pane indices) is user-facing.
522    // -----------------------------------------------------------------------
523
524    #[test]
525    fn each_pane_receives_cd_and_cli_command() {
526        let session = TmuxSessionBuilder::new("proj")
527            .add_pane(make_pane("feat/auth", "/home/user/wt-auth", "claude"))
528            .add_pane(make_pane("feat/api", "/home/user/wt-api", "gemini"))
529            .build()
530            .unwrap();
531
532        let cmds = session.command_strings();
533        let send_keys = commands_containing(&cmds, "send-keys");
534
535        // Pane 0 uses -c on new-session for its directory, so just runs the CLI
536        assert!(
537            send_keys[0].contains("claude"),
538            "first pane should run claude"
539        );
540        // Subsequent panes use cd && cli
541        assert!(
542            send_keys[1].contains("cd /home/user/wt-api && gemini"),
543            "second pane should cd into wt-api and run gemini"
544        );
545    }
546
547    #[test]
548    fn pane_commands_are_submitted_with_enter() {
549        let session = TmuxSessionBuilder::new("proj")
550            .add_pane(make_pane("main", "/tmp/wt", "aider"))
551            .build()
552            .unwrap();
553
554        let cmds = session.command_strings();
555        let send_keys = commands_containing(&cmds, "send-keys");
556        assert!(
557            send_keys[0].contains("Enter"),
558            "send-keys should press Enter to submit"
559        );
560    }
561
562    #[test]
563    fn each_pane_targets_a_distinct_pane_index() {
564        let session = TmuxSessionBuilder::new("proj")
565            .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
566            .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
567            .add_pane(make_pane("feat/c", "/tmp/c", "gemini"))
568            .build()
569            .unwrap();
570
571        let cmds = session.command_strings();
572        let send_keys = commands_containing(&cmds, "send-keys");
573
574        assert!(
575            send_keys[0].contains(":0.0"),
576            "first pane should target :0.0"
577        );
578        assert!(
579            send_keys[1].contains(":0.1"),
580            "second pane should target :0.1"
581        );
582        assert!(
583            send_keys[2].contains(":0.2"),
584            "third pane should target :0.2"
585        );
586    }
587
588    // -----------------------------------------------------------------------
589    // AC: Pane titles show branch and CLI
590    // Dry-run contract: title format is user-visible in both --dry-run output
591    // and tmux pane borders. Actual tmux titles verified by e2e test
592    // tmux_session_with_five_panes_and_different_clis.
593    // -----------------------------------------------------------------------
594
595    #[test]
596    fn each_pane_is_titled_with_branch_and_cli() {
597        let session = TmuxSessionBuilder::new("proj")
598            .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
599            .add_pane(make_pane("fix/api", "/tmp/wt2", "gemini"))
600            .build()
601            .unwrap();
602
603        let cmds = session.command_strings();
604        let select_panes = commands_containing(&cmds, "select-pane");
605
606        assert_eq!(select_panes.len(), 2, "each pane should get a title");
607        assert!(
608            select_panes[0].contains("feat/auth \u{2192} claude"),
609            "first pane title should be 'feat/auth \u{2192} claude', got: {}",
610            select_panes[0]
611        );
612        assert!(
613            select_panes[1].contains("fix/api \u{2192} gemini"),
614            "second pane title should be 'fix/api \u{2192} gemini', got: {}",
615            select_panes[1]
616        );
617    }
618
619    #[test]
620    fn pane_border_status_is_configured() {
621        let session = TmuxSessionBuilder::new("proj")
622            .add_pane(make_pane("main", "/tmp/wt", "claude"))
623            .build()
624            .unwrap();
625
626        let cmds = session.command_strings();
627        assert!(
628            cmds.iter()
629                .any(|c| c.contains("pane-border-status") && c.contains("top")),
630            "should configure pane-border-status to top"
631        );
632        assert!(
633            cmds.iter()
634                .any(|c| c.contains("pane-border-format") && c.contains("#{pane_title}")),
635            "should configure pane-border-format to show pane title"
636        );
637    }
638
639    // -----------------------------------------------------------------------
640    // AC: Mouse mode (per-session, configurable, default on)
641    // Dry-run contract: users see mouse config in --dry-run output.
642    // Actual tmux behavior verified by e2e test tmux_mouse_mode_enabled_by_default.
643    // -----------------------------------------------------------------------
644
645    #[test]
646    fn mouse_mode_enabled_by_default() {
647        let session = TmuxSessionBuilder::new("proj")
648            .add_pane(make_pane("main", "/tmp/wt", "claude"))
649            .build()
650            .unwrap();
651
652        let cmds = session.command_strings();
653        assert!(
654            cmds.iter().any(|c| c.contains("mouse on")),
655            "mouse should be enabled by default"
656        );
657    }
658
659    #[test]
660    fn mouse_mode_can_be_disabled() {
661        let session = TmuxSessionBuilder::new("proj")
662            .add_pane(make_pane("main", "/tmp/wt", "claude"))
663            .mouse_mode(false)
664            .build()
665            .unwrap();
666
667        let cmds = session.command_strings();
668        assert!(
669            !cmds.iter().any(|c| c.contains("mouse on")),
670            "no mouse-on command should be emitted when disabled"
671        );
672    }
673
674    // -----------------------------------------------------------------------
675    // AC: Session liveness and collision handling
676    // Behavioral: tests against a real tmux server — verifies observable
677    // outcomes (session exists, session is killed, names are unique).
678    // -----------------------------------------------------------------------
679
680    /// Helper to create a detached tmux session for testing.
681    fn create_test_session(name: &str) {
682        let output = std::process::Command::new("tmux")
683            .args(["new-session", "-d", "-s", name])
684            .output()
685            .expect("create tmux session");
686        assert!(
687            output.status.success(),
688            "failed to create test session '{name}'"
689        );
690    }
691
692    /// Helper to kill a tmux session, ignoring errors.
693    fn cleanup_session(name: &str) {
694        let _ = kill_session(name);
695    }
696
697    #[test]
698    #[serial_test::serial]
699    fn is_session_alive_returns_false_for_nonexistent() {
700        let alive = is_session_alive("paw-definitely-does-not-exist-12345").unwrap();
701        assert!(!alive);
702    }
703
704    #[test]
705    #[serial_test::serial]
706    fn session_lifecycle_create_check_kill() {
707        let name = "paw-unit-test-lifecycle";
708        cleanup_session(name);
709
710        create_test_session(name);
711        assert!(is_session_alive(name).unwrap());
712
713        kill_session(name).unwrap();
714        assert!(!is_session_alive(name).unwrap());
715    }
716
717    #[test]
718    #[serial_test::serial]
719    fn resolve_session_name_returns_base_when_no_collision() {
720        let name = resolve_session_name("unit-test-no-collision-xyz").unwrap();
721        assert_eq!(name, "paw-unit-test-no-collision-xyz");
722    }
723
724    #[test]
725    #[serial_test::serial]
726    fn resolve_session_name_appends_suffix_on_collision() {
727        let base_name = "paw-unit-test-collision";
728        cleanup_session(base_name);
729        cleanup_session(&format!("{base_name}-2"));
730
731        create_test_session(base_name);
732
733        let resolved = resolve_session_name("unit-test-collision").unwrap();
734        assert_eq!(resolved, format!("{base_name}-2"));
735
736        cleanup_session(base_name);
737    }
738
739    // -----------------------------------------------------------------------
740    // AC: pipe-pane logging integration
741    // Dry-run contract: verifies the pipe-pane command is queued correctly.
742    // -----------------------------------------------------------------------
743
744    #[test]
745    fn pipe_pane_queues_correct_command() {
746        let mut session = TmuxSessionBuilder::new("proj")
747            .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
748            .build()
749            .unwrap();
750
751        let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/feat--auth.log");
752        session.pipe_pane("paw-proj:0.0", &log_path);
753
754        let cmds = session.command_strings();
755        let pipe_cmds: Vec<&String> = cmds.iter().filter(|c| c.contains("pipe-pane")).collect();
756        assert_eq!(pipe_cmds.len(), 1);
757        assert!(pipe_cmds[0].contains("pipe-pane -o -t paw-proj:0.0"));
758        assert!(pipe_cmds[0].contains("cat >> /repo/.git-paw/logs/paw-proj/feat--auth.log"));
759    }
760
761    // --- Gap #10: pipe-pane conditional on logging ---
762
763    #[test]
764    fn session_without_pipe_pane_has_no_pipe_pane_commands() {
765        let session = TmuxSessionBuilder::new("proj")
766            .add_pane(make_pane("main", "/tmp/wt", "claude"))
767            .build()
768            .unwrap();
769
770        let cmds = session.command_strings();
771        assert!(
772            !cmds.iter().any(|c| c.contains("pipe-pane")),
773            "session built without pipe_pane calls should have no pipe-pane commands"
774        );
775    }
776
777    #[test]
778    fn session_with_pipe_pane_differs_from_without() {
779        let session_without = TmuxSessionBuilder::new("proj")
780            .add_pane(make_pane("main", "/tmp/wt", "claude"))
781            .build()
782            .unwrap();
783        let cmds_without = session_without.command_strings();
784
785        let mut session_with = TmuxSessionBuilder::new("proj")
786            .add_pane(make_pane("main", "/tmp/wt", "claude"))
787            .build()
788            .unwrap();
789        let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
790        session_with.pipe_pane("paw-proj:0.0", &log_path);
791        let cmds_with = session_with.command_strings();
792
793        assert_ne!(
794            cmds_without, cmds_with,
795            "command lists should differ when pipe-pane is added"
796        );
797        assert!(
798            cmds_with.iter().any(|c| c.contains("pipe-pane")),
799            "session with pipe_pane should contain pipe-pane command"
800        );
801    }
802
803    // --- Gap #11: pipe-pane ordering ---
804
805    #[test]
806    fn pipe_pane_appears_after_send_keys_for_pane() {
807        let mut session = TmuxSessionBuilder::new("proj")
808            .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
809            .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
810            .build()
811            .unwrap();
812
813        let log0 = std::path::PathBuf::from("/repo/logs/feat--auth.log");
814        let log1 = std::path::PathBuf::from("/repo/logs/feat--api.log");
815        session.pipe_pane("paw-proj:0.0", &log0);
816        session.pipe_pane("paw-proj:0.1", &log1);
817
818        let cmds = session.command_strings();
819
820        // Find the last send-keys index and first pipe-pane index
821        let last_send_keys = cmds
822            .iter()
823            .rposition(|c| c.contains("send-keys"))
824            .expect("should have send-keys");
825        let first_pipe_pane = cmds
826            .iter()
827            .position(|c| c.contains("pipe-pane"))
828            .expect("should have pipe-pane");
829
830        assert!(
831            first_pipe_pane > last_send_keys,
832            "pipe-pane commands (index {first_pipe_pane}) should appear after \
833             all send-keys commands (last at index {last_send_keys})"
834        );
835    }
836
837    #[test]
838    fn pipe_pane_appears_in_dry_run_output() {
839        let mut session = TmuxSessionBuilder::new("proj")
840            .add_pane(make_pane("main", "/tmp/wt", "claude"))
841            .build()
842            .unwrap();
843
844        let log_path = std::path::PathBuf::from("/repo/.git-paw/logs/paw-proj/main.log");
845        session.pipe_pane("paw-proj:0.0", &log_path);
846
847        let cmds = session.command_strings();
848        assert!(
849            cmds.iter().any(|c| c.starts_with("tmux pipe-pane")),
850            "dry-run output should include pipe-pane command"
851        );
852    }
853
854    #[test]
855    #[serial_test::serial]
856    fn built_session_can_be_executed_and_killed() {
857        let project = "unit-test-execute";
858        let session_name = format!("paw-{project}");
859        cleanup_session(&session_name);
860
861        let session = TmuxSessionBuilder::new(project)
862            .add_pane(make_pane("main", "/tmp", "echo hello"))
863            .build()
864            .unwrap();
865
866        session.execute().unwrap();
867        assert!(is_session_alive(&session_name).unwrap());
868
869        kill_session(&session_name).unwrap();
870        assert!(!is_session_alive(&session_name).unwrap());
871    }
872}