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 env_vars: Vec<(String, String)>,
145}
146
147impl TmuxSessionBuilder {
148 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 #[must_use]
166 pub fn session_name(mut self, name: String) -> Self {
167 self.session_name_override = Some(name);
168 self
169 }
170
171 #[must_use]
173 pub fn add_pane(mut self, spec: PaneSpec) -> Self {
174 self.panes.push(spec);
175 self
176 }
177
178 #[must_use]
183 pub fn mouse_mode(mut self, enabled: bool) -> Self {
184 self.mouse_mode = enabled;
185 self
186 }
187
188 #[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 #[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 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 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 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 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 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 for (i, pane) in self.panes.iter().enumerate().skip(1) {
287 commands.push(TmuxCommand::new(&[
289 "select-layout",
290 "-t",
291 &session_name,
292 "tiled",
293 ]));
294
295 commands.push(TmuxCommand::new(&["split-window", "-t", &session_name]));
297
298 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 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
333pub fn ensure_tmux_installed() -> Result<(), PawError> {
338 which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
339 Ok(())
340}
341
342pub 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
354pub 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
377pub 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
396pub 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 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 #[test]
437 #[serial_test::serial]
438 fn ensure_tmux_installed_succeeds_when_present() {
439 assert!(ensure_tmux_installed().is_ok());
441 }
442
443 #[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 #[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 #[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 assert!(
561 send_keys[0].contains("claude"),
562 "first pane should run claude"
563 );
564 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 #[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 #[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 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 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 #[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 #[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 #[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 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 #[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}