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 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 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#[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 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 #[must_use]
196 pub fn session_name(mut self, name: String) -> Self {
197 self.session_name_override = Some(name);
198 self
199 }
200
201 #[must_use]
203 pub fn add_pane(mut self, spec: PaneSpec) -> Self {
204 self.panes.push(spec);
205 self
206 }
207
208 #[must_use]
213 pub fn mouse_mode(mut self, enabled: bool) -> Self {
214 self.mouse_mode = enabled;
215 self
216 }
217
218 #[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 #[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 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 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 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 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 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 for (i, pane) in self.panes.iter().enumerate().skip(1) {
317 commands.push(TmuxCommand::new(&[
319 "select-layout",
320 "-t",
321 &session_name,
322 "tiled",
323 ]));
324
325 commands.push(TmuxCommand::new(&["split-window", "-t", &session_name]));
327
328 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 if self.panes.len() > 1 && self.panes[0].branch == "dashboard" {
350 commands.push(TmuxCommand::new(&[
352 "select-layout",
353 "-t",
354 &session_name,
355 "main-horizontal",
356 ]));
357 } else {
358 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
374pub fn ensure_tmux_installed() -> Result<(), PawError> {
379 which::which("tmux").map_err(|_| PawError::TmuxNotInstalled)?;
380 Ok(())
381}
382
383pub 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
395pub 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
418pub 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
437pub 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
452pub 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 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 #[test]
496 #[serial_test::serial]
497 fn ensure_tmux_installed_succeeds_when_present() {
498 assert!(ensure_tmux_installed().is_ok());
500 }
501
502 #[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 #[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 #[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 assert!(
620 send_keys[0].contains("claude"),
621 "first pane should run claude"
622 );
623 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 #[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 #[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 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 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 #[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 #[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 #[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 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 #[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 #[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}