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