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
95#[derive(Debug)]
124pub struct TmuxSessionBuilder {
125 project_name: String,
126 panes: Vec<PaneSpec>,
127 mouse_mode: bool,
128 session_name_override: Option<String>,
129}
130
131impl TmuxSessionBuilder {
132 pub fn new(project_name: &str) -> Self {
137 Self {
138 project_name: project_name.to_owned(),
139 panes: Vec::new(),
140 mouse_mode: true,
141 session_name_override: None,
142 }
143 }
144
145 #[must_use]
149 pub fn session_name(mut self, name: String) -> Self {
150 self.session_name_override = Some(name);
151 self
152 }
153
154 #[must_use]
156 pub fn add_pane(mut self, spec: PaneSpec) -> Self {
157 self.panes.push(spec);
158 self
159 }
160
161 #[must_use]
166 pub fn mouse_mode(mut self, enabled: bool) -> Self {
167 self.mouse_mode = enabled;
168 self
169 }
170
171 pub fn build(self) -> Result<TmuxSession, PawError> {
176 if self.panes.is_empty() {
177 return Err(PawError::TmuxError(
178 "cannot create a session with no panes".to_owned(),
179 ));
180 }
181
182 let session_name = self
183 .session_name_override
184 .unwrap_or_else(|| format!("paw-{}", self.project_name));
185 let mut commands = Vec::new();
186
187 commands.push(TmuxCommand::new(&[
189 "new-session",
190 "-d",
191 "-s",
192 &session_name,
193 ]));
194
195 if self.mouse_mode {
197 commands.push(TmuxCommand::new(&[
198 "set-option",
199 "-t",
200 &session_name,
201 "mouse",
202 "on",
203 ]));
204 }
205
206 commands.push(TmuxCommand::new(&[
208 "set-option",
209 "-t",
210 &session_name,
211 "pane-border-status",
212 "top",
213 ]));
214 commands.push(TmuxCommand::new(&[
215 "set-option",
216 "-t",
217 &session_name,
218 "pane-border-format",
219 " #{pane_title} ",
220 ]));
221
222 let first = &self.panes[0];
224 let pane_target = format!("{session_name}:0.0");
225 let pane_title = format!("{} \u{2192} {}", first.branch, first.cli_command);
226 let pane_cmd = format!("cd {} && {}", first.worktree, first.cli_command);
227 commands.push(TmuxCommand::new(&[
228 "select-pane",
229 "-t",
230 &pane_target,
231 "-T",
232 &pane_title,
233 ]));
234 commands.push(TmuxCommand::new(&[
235 "send-keys",
236 "-t",
237 &pane_target,
238 &pane_cmd,
239 "Enter",
240 ]));
241
242 for (i, pane) in self.panes.iter().enumerate().skip(1) {
244 commands.push(TmuxCommand::new(&[
246 "select-layout",
247 "-t",
248 &session_name,
249 "tiled",
250 ]));
251
252 commands.push(TmuxCommand::new(&["split-window", "-t", &session_name]));
254
255 let pane_target = format!("{session_name}:0.{i}");
257 let pane_title = format!("{} \u{2192} {}", pane.branch, pane.cli_command);
258 let pane_cmd = format!("cd {} && {}", pane.worktree, pane.cli_command);
259 commands.push(TmuxCommand::new(&[
260 "select-pane",
261 "-t",
262 &pane_target,
263 "-T",
264 &pane_title,
265 ]));
266 commands.push(TmuxCommand::new(&[
267 "send-keys",
268 "-t",
269 &pane_target,
270 &pane_cmd,
271 "Enter",
272 ]));
273 }
274
275 commands.push(TmuxCommand::new(&[
277 "select-layout",
278 "-t",
279 &session_name,
280 "tiled",
281 ]));
282
283 Ok(TmuxSession {
284 name: session_name,
285 commands,
286 })
287 }
288}
289
290pub fn ensure_tmux_installed() -> Result<(), PawError> {
295 which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
296 Ok(())
297}
298
299pub fn is_session_alive(name: &str) -> Result<bool, PawError> {
301 let status = Command::new("tmux")
302 .args(["has-session", "-t", name])
303 .stdout(std::process::Stdio::null())
304 .stderr(std::process::Stdio::null())
305 .status()
306 .map_err(|e| PawError::TmuxError(format!("failed to run tmux: {e}")))?;
307
308 Ok(status.success())
309}
310
311pub fn resolve_session_name(project_name: &str) -> Result<String, PawError> {
316 let base = format!("paw-{project_name}");
317
318 if !is_session_alive(&base)? {
319 return Ok(base);
320 }
321
322 for suffix in 2..=MAX_COLLISION_RETRIES + 1 {
323 let candidate = format!("{base}-{suffix}");
324 if !is_session_alive(&candidate)? {
325 return Ok(candidate);
326 }
327 }
328
329 Err(PawError::TmuxError(format!(
330 "too many session name collisions for '{base}'"
331 )))
332}
333
334pub fn attach(name: &str) -> Result<(), PawError> {
339 let status = Command::new("tmux")
340 .args(["attach-session", "-t", name])
341 .status()
342 .map_err(|e| PawError::TmuxError(format!("failed to attach to tmux session: {e}")))?;
343
344 if status.success() {
345 Ok(())
346 } else {
347 Err(PawError::TmuxError(format!(
348 "failed to attach to session '{name}'"
349 )))
350 }
351}
352
353pub fn kill_session(name: &str) -> Result<(), PawError> {
355 let output = Command::new("tmux")
356 .args(["kill-session", "-t", name])
357 .output()
358 .map_err(|e| PawError::TmuxError(format!("failed to kill tmux session: {e}")))?;
359
360 if output.status.success() {
361 Ok(())
362 } else {
363 let stderr = String::from_utf8_lossy(&output.stderr);
364 Err(PawError::TmuxError(stderr.trim().to_owned()))
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 fn make_pane(branch: &str, worktree: &str, cli: &str) -> PaneSpec {
373 PaneSpec {
374 branch: branch.to_owned(),
375 worktree: worktree.to_owned(),
376 cli_command: cli.to_owned(),
377 }
378 }
379
380 fn commands_containing(cmds: &[String], keyword: &str) -> Vec<String> {
382 cmds.iter()
383 .filter(|c| c.contains(keyword))
384 .cloned()
385 .collect()
386 }
387
388 #[test]
394 #[serial_test::serial]
395 fn ensure_tmux_installed_succeeds_when_present() {
396 assert!(ensure_tmux_installed().is_ok());
398 }
399
400 #[test]
407 fn session_is_named_after_project() {
408 let session = TmuxSessionBuilder::new("my-project")
409 .add_pane(make_pane("main", "/tmp/wt", "claude"))
410 .build()
411 .unwrap();
412
413 assert_eq!(session.name, "paw-my-project");
414 }
415
416 #[test]
417 fn session_creation_command_uses_session_name() {
418 let session = TmuxSessionBuilder::new("app")
419 .add_pane(make_pane("main", "/tmp/wt", "claude"))
420 .build()
421 .unwrap();
422
423 let cmds = session.command_strings();
424 assert!(
425 cmds.iter()
426 .any(|c| c.contains("new-session") && c.contains("paw-app")),
427 "should create a tmux session named paw-app"
428 );
429 }
430
431 #[test]
432 fn session_name_override_replaces_default() {
433 let session = TmuxSessionBuilder::new("my-project")
434 .session_name("custom-session-name".to_string())
435 .add_pane(make_pane("main", "/tmp/wt", "claude"))
436 .build()
437 .unwrap();
438
439 assert_eq!(session.name, "custom-session-name");
440 let cmds = session.command_strings();
441 assert!(
442 cmds.iter()
443 .any(|c| c.contains("new-session") && c.contains("custom-session-name")),
444 "should use overridden session name"
445 );
446 }
447
448 #[test]
456 fn pane_count_matches_input_for_two_panes() {
457 let session = TmuxSessionBuilder::new("proj")
458 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
459 .add_pane(make_pane("feat/api", "/tmp/wt2", "codex"))
460 .build()
461 .unwrap();
462
463 let cmds = session.command_strings();
464 let send_keys = commands_containing(&cmds, "send-keys");
465 assert_eq!(
466 send_keys.len(),
467 2,
468 "should send commands to exactly 2 panes"
469 );
470 }
471
472 #[test]
473 fn pane_count_matches_input_for_five_panes() {
474 let mut builder = TmuxSessionBuilder::new("proj");
475 for i in 0..5 {
476 builder = builder.add_pane(make_pane(
477 &format!("feat/b{i}"),
478 &format!("/tmp/wt{i}"),
479 "claude",
480 ));
481 }
482 let session = builder.build().unwrap();
483
484 let cmds = session.command_strings();
485 let send_keys = commands_containing(&cmds, "send-keys");
486 assert_eq!(
487 send_keys.len(),
488 5,
489 "should send commands to exactly 5 panes"
490 );
491 }
492
493 #[test]
494 fn building_with_no_panes_is_an_error() {
495 let result = TmuxSessionBuilder::new("proj").build();
496 assert!(result.is_err(), "session with no panes should fail");
497 }
498
499 #[test]
506 fn each_pane_receives_cd_and_cli_command() {
507 let session = TmuxSessionBuilder::new("proj")
508 .add_pane(make_pane("feat/auth", "/home/user/wt-auth", "claude"))
509 .add_pane(make_pane("feat/api", "/home/user/wt-api", "gemini"))
510 .build()
511 .unwrap();
512
513 let cmds = session.command_strings();
514 let send_keys = commands_containing(&cmds, "send-keys");
515
516 assert!(
517 send_keys[0].contains("cd /home/user/wt-auth && claude"),
518 "first pane should cd into wt-auth and run claude"
519 );
520 assert!(
521 send_keys[1].contains("cd /home/user/wt-api && gemini"),
522 "second pane should cd into wt-api and run gemini"
523 );
524 }
525
526 #[test]
527 fn pane_commands_are_submitted_with_enter() {
528 let session = TmuxSessionBuilder::new("proj")
529 .add_pane(make_pane("main", "/tmp/wt", "aider"))
530 .build()
531 .unwrap();
532
533 let cmds = session.command_strings();
534 let send_keys = commands_containing(&cmds, "send-keys");
535 assert!(
536 send_keys[0].contains("Enter"),
537 "send-keys should press Enter to submit"
538 );
539 }
540
541 #[test]
542 fn each_pane_targets_a_distinct_pane_index() {
543 let session = TmuxSessionBuilder::new("proj")
544 .add_pane(make_pane("feat/a", "/tmp/a", "claude"))
545 .add_pane(make_pane("feat/b", "/tmp/b", "codex"))
546 .add_pane(make_pane("feat/c", "/tmp/c", "gemini"))
547 .build()
548 .unwrap();
549
550 let cmds = session.command_strings();
551 let send_keys = commands_containing(&cmds, "send-keys");
552
553 assert!(
554 send_keys[0].contains(":0.0"),
555 "first pane should target :0.0"
556 );
557 assert!(
558 send_keys[1].contains(":0.1"),
559 "second pane should target :0.1"
560 );
561 assert!(
562 send_keys[2].contains(":0.2"),
563 "third pane should target :0.2"
564 );
565 }
566
567 #[test]
575 fn each_pane_is_titled_with_branch_and_cli() {
576 let session = TmuxSessionBuilder::new("proj")
577 .add_pane(make_pane("feat/auth", "/tmp/wt1", "claude"))
578 .add_pane(make_pane("fix/api", "/tmp/wt2", "gemini"))
579 .build()
580 .unwrap();
581
582 let cmds = session.command_strings();
583 let select_panes = commands_containing(&cmds, "select-pane");
584
585 assert_eq!(select_panes.len(), 2, "each pane should get a title");
586 assert!(
587 select_panes[0].contains("feat/auth \u{2192} claude"),
588 "first pane title should be 'feat/auth \u{2192} claude', got: {}",
589 select_panes[0]
590 );
591 assert!(
592 select_panes[1].contains("fix/api \u{2192} gemini"),
593 "second pane title should be 'fix/api \u{2192} gemini', got: {}",
594 select_panes[1]
595 );
596 }
597
598 #[test]
599 fn pane_border_status_is_configured() {
600 let session = TmuxSessionBuilder::new("proj")
601 .add_pane(make_pane("main", "/tmp/wt", "claude"))
602 .build()
603 .unwrap();
604
605 let cmds = session.command_strings();
606 assert!(
607 cmds.iter()
608 .any(|c| c.contains("pane-border-status") && c.contains("top")),
609 "should configure pane-border-status to top"
610 );
611 assert!(
612 cmds.iter()
613 .any(|c| c.contains("pane-border-format") && c.contains("#{pane_title}")),
614 "should configure pane-border-format to show pane title"
615 );
616 }
617
618 #[test]
625 fn mouse_mode_enabled_by_default() {
626 let session = TmuxSessionBuilder::new("proj")
627 .add_pane(make_pane("main", "/tmp/wt", "claude"))
628 .build()
629 .unwrap();
630
631 let cmds = session.command_strings();
632 assert!(
633 cmds.iter().any(|c| c.contains("mouse on")),
634 "mouse should be enabled by default"
635 );
636 }
637
638 #[test]
639 fn mouse_mode_can_be_disabled() {
640 let session = TmuxSessionBuilder::new("proj")
641 .add_pane(make_pane("main", "/tmp/wt", "claude"))
642 .mouse_mode(false)
643 .build()
644 .unwrap();
645
646 let cmds = session.command_strings();
647 assert!(
648 !cmds.iter().any(|c| c.contains("mouse on")),
649 "no mouse-on command should be emitted when disabled"
650 );
651 }
652
653 fn create_test_session(name: &str) {
661 let output = std::process::Command::new("tmux")
662 .args(["new-session", "-d", "-s", name])
663 .output()
664 .expect("create tmux session");
665 assert!(
666 output.status.success(),
667 "failed to create test session '{name}'"
668 );
669 }
670
671 fn cleanup_session(name: &str) {
673 let _ = kill_session(name);
674 }
675
676 #[test]
677 #[serial_test::serial]
678 fn is_session_alive_returns_false_for_nonexistent() {
679 let alive = is_session_alive("paw-definitely-does-not-exist-12345").unwrap();
680 assert!(!alive);
681 }
682
683 #[test]
684 #[serial_test::serial]
685 fn session_lifecycle_create_check_kill() {
686 let name = "paw-unit-test-lifecycle";
687 cleanup_session(name);
688
689 create_test_session(name);
690 assert!(is_session_alive(name).unwrap());
691
692 kill_session(name).unwrap();
693 assert!(!is_session_alive(name).unwrap());
694 }
695
696 #[test]
697 #[serial_test::serial]
698 fn resolve_session_name_returns_base_when_no_collision() {
699 let name = resolve_session_name("unit-test-no-collision-xyz").unwrap();
700 assert_eq!(name, "paw-unit-test-no-collision-xyz");
701 }
702
703 #[test]
704 #[serial_test::serial]
705 fn resolve_session_name_appends_suffix_on_collision() {
706 let base_name = "paw-unit-test-collision";
707 cleanup_session(base_name);
708 cleanup_session(&format!("{base_name}-2"));
709
710 create_test_session(base_name);
711
712 let resolved = resolve_session_name("unit-test-collision").unwrap();
713 assert_eq!(resolved, format!("{base_name}-2"));
714
715 cleanup_session(base_name);
716 }
717
718 #[test]
719 #[serial_test::serial]
720 fn built_session_can_be_executed_and_killed() {
721 let project = "unit-test-execute";
722 let session_name = format!("paw-{project}");
723 cleanup_session(&session_name);
724
725 let session = TmuxSessionBuilder::new(project)
726 .add_pane(make_pane("main", "/tmp", "echo hello"))
727 .build()
728 .unwrap();
729
730 session.execute().unwrap();
731 assert!(is_session_alive(&session_name).unwrap());
732
733 kill_session(&session_name).unwrap();
734 assert!(!is_session_alive(&session_name).unwrap());
735 }
736}