1use std::process::Command;
8
9use crate::error::PawError;
10
11const MAX_COLLISION_RETRIES: u32 = 10;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct TmuxCommand {
19 args: Vec<String>,
20}
21
22impl TmuxCommand {
23 fn new(args: &[&str]) -> Self {
25 Self {
26 args: args.iter().map(|&s| s.to_owned()).collect(),
27 }
28 }
29
30 #[allow(dead_code)]
34 pub fn as_command_string(&self) -> String {
35 format!("tmux {}", self.args.join(" "))
36 }
37
38 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#[derive(Debug, Clone)]
57pub struct PaneSpec {
58 pub branch: String,
60 pub worktree: String,
62 pub cli_command: String,
64}
65
66#[derive(Debug)]
68pub struct TmuxSession {
69 pub name: String,
71 commands: Vec<TmuxCommand>,
72}
73
74impl TmuxSession {
75 pub fn execute(&self) -> Result<(), PawError> {
77 for cmd in &self.commands {
78 cmd.execute()?;
79 }
80 Ok(())
81 }
82
83 #[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 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#[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 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 #[must_use]
164 pub fn session_name(mut self, name: String) -> Self {
165 self.session_name_override = Some(name);
166 self
167 }
168
169 #[must_use]
171 pub fn add_pane(mut self, spec: PaneSpec) -> Self {
172 self.panes.push(spec);
173 self
174 }
175
176 #[must_use]
181 pub fn mouse_mode(mut self, enabled: bool) -> Self {
182 self.mouse_mode = enabled;
183 self
184 }
185
186 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 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 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 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 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 for (i, pane) in self.panes.iter().enumerate().skip(1) {
263 commands.push(TmuxCommand::new(&[
265 "select-layout",
266 "-t",
267 &session_name,
268 "tiled",
269 ]));
270
271 commands.push(TmuxCommand::new(&["split-window", "-t", &session_name]));
273
274 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 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
309pub fn ensure_tmux_installed() -> Result<(), PawError> {
314 which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
315 Ok(())
316}
317
318pub 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
330pub 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
353pub 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
372pub 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 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 #[test]
413 #[serial_test::serial]
414 fn ensure_tmux_installed_succeeds_when_present() {
415 assert!(ensure_tmux_installed().is_ok());
417 }
418
419 #[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 #[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 #[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 assert!(
537 send_keys[0].contains("claude"),
538 "first pane should run claude"
539 );
540 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 #[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 #[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 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 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 #[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 #[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 #[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 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}