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