Skip to main content

smux/
tmux.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use anyhow::{Context, Result, bail};
5
6use crate::process::{CommandOutput, CommandRunner, default_runner};
7use crate::templates::{PaneLayout, PanePosition, SessionPlan};
8use crate::util;
9
10#[derive(Debug, Clone, Eq, PartialEq)]
11pub struct SessionSnapshot {
12    pub session_name: String,
13    pub active_window: String,
14    pub active_pane: usize,
15    pub active_path: std::path::PathBuf,
16    pub windows: Vec<WindowSnapshot>,
17}
18
19#[derive(Debug, Clone, Eq, PartialEq)]
20pub struct WindowSnapshot {
21    pub name: String,
22    pub synchronize: bool,
23    pub active: bool,
24    pub panes: Vec<PaneSnapshot>,
25}
26
27#[derive(Debug, Clone, Eq, PartialEq)]
28pub struct PaneSnapshot {
29    pub cwd: std::path::PathBuf,
30    pub active: bool,
31    pub layout: Option<PaneLayout>,
32}
33
34#[derive(Debug, Clone, Eq, PartialEq)]
35struct WindowRecord {
36    id: String,
37    name: String,
38    active: bool,
39}
40
41#[derive(Debug, Clone, Eq, PartialEq)]
42struct PaneRecord {
43    index: usize,
44    cwd: std::path::PathBuf,
45    active: bool,
46    left: i32,
47    top: i32,
48    width: i32,
49    height: i32,
50}
51
52#[derive(Clone)]
53pub struct Tmux {
54    runner: Arc<dyn CommandRunner>,
55}
56
57impl Default for Tmux {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl Tmux {
64    pub fn new() -> Self {
65        Self {
66            runner: default_runner(),
67        }
68    }
69
70    pub fn with_runner(runner: Arc<dyn CommandRunner>) -> Self {
71        Self { runner }
72    }
73
74    pub fn list_sessions(&self) -> Result<Vec<String>> {
75        let output = self.runner.run_capture(
76            "tmux",
77            &[
78                "list-sessions".to_owned(),
79                "-F".to_owned(),
80                "#{session_name}".to_owned(),
81            ],
82        );
83
84        match output {
85            Ok(output) if output.status.success => {
86                let stdout =
87                    String::from_utf8(output.stdout).context("tmux output was not utf-8")?;
88                Ok(stdout
89                    .lines()
90                    .map(str::trim)
91                    .filter(|line| !line.is_empty())
92                    .map(ToOwned::to_owned)
93                    .collect())
94            }
95            Ok(output) => {
96                let stderr = String::from_utf8_lossy(&output.stderr);
97
98                if stderr.contains("no server running") {
99                    Ok(Vec::new())
100                } else {
101                    bail!("tmux list-sessions failed: {}", stderr.trim())
102                }
103            }
104            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
105                bail!("tmux is not installed or not on PATH")
106            }
107            Err(error) => Err(error).context("failed to execute tmux list-sessions"),
108        }
109    }
110
111    pub fn current_session(&self) -> Result<Option<String>> {
112        if !util::inside_tmux() {
113            return Ok(None);
114        }
115
116        let output = self
117            .runner
118            .run_capture(
119                "tmux",
120                &[
121                    "display-message".to_owned(),
122                    "-p".to_owned(),
123                    "#{session_name}".to_owned(),
124                ],
125            )
126            .context("failed to execute tmux display-message")?;
127
128        if !output.status.success {
129            let stderr = String::from_utf8_lossy(&output.stderr);
130            bail!("tmux display-message failed: {}", stderr.trim());
131        }
132
133        let stdout = String::from_utf8(output.stdout).context("tmux output was not utf-8")?;
134        let session = stdout.trim();
135        if session.is_empty() {
136            Ok(None)
137        } else {
138            Ok(Some(session.to_owned()))
139        }
140    }
141
142    pub fn has_session(&self, session: &str) -> Result<bool> {
143        let output = self
144            .runner
145            .run_capture(
146                "tmux",
147                &[
148                    "has-session".to_owned(),
149                    "-t".to_owned(),
150                    session.to_owned(),
151                ],
152            )
153            .context("failed to execute tmux has-session")?;
154
155        Ok(output.status.success)
156    }
157
158    pub fn ensure_session_exists(&self, session: &str) -> Result<()> {
159        if self.has_session(session)? {
160            Ok(())
161        } else {
162            bail!("tmux session not found: {session}")
163        }
164    }
165
166    pub fn create_session(&self, session: &str, directory: &Path) -> Result<()> {
167        let directory = util::path_to_string(directory)?;
168        let output = self
169            .run_tmux_capture([
170                "new-session",
171                "-d",
172                "-s",
173                session,
174                "-c",
175                &directory,
176                "-n",
177                "main",
178            ])
179            .context("failed to execute tmux new-session")?;
180
181        if output.status.success {
182            Ok(())
183        } else {
184            let stderr = String::from_utf8_lossy(&output.stderr);
185            bail!("tmux new-session failed: {}", stderr.trim())
186        }
187    }
188
189    pub fn create_session_from_plan(&self, plan: &SessionPlan) -> Result<()> {
190        let first_window = plan
191            .windows
192            .first()
193            .context("session plan must contain at least one window")?;
194
195        self.create_session_with_window(&plan.session_name, &first_window.name, &first_window.cwd)?;
196        self.configure_panes(&plan.session_name, &first_window.name, first_window)?;
197
198        for window in plan.windows.iter().skip(1) {
199            self.new_window(&plan.session_name, &window.name, &window.cwd)?;
200            self.configure_panes(&plan.session_name, &window.name, window)?;
201        }
202
203        self.select_window(&plan.session_name, &plan.startup_window)?;
204        self.select_pane_by_offset(&plan.session_name, &plan.startup_window, plan.startup_pane)?;
205        Ok(())
206    }
207
208    pub fn switch_or_attach(&self, session: &str) -> Result<()> {
209        if util::inside_tmux() {
210            self.run_tmux(["switch-client", "-t", session])
211                .context("failed to execute tmux switch-client")
212        } else {
213            let args = vec![
214                "attach-session".to_owned(),
215                "-t".to_owned(),
216                session.to_owned(),
217            ];
218
219            let status = self
220                .runner
221                .run_inherit("tmux", &args)
222                .context("failed to execute tmux attach-session")?;
223
224            if status.success {
225                Ok(())
226            } else {
227                bail!("tmux attach-session failed with status {:?}", status.code)
228            }
229        }
230    }
231
232    pub fn kill_session(&self, session: &str) -> Result<()> {
233        self.run_tmux(["kill-session", "-t", session])
234            .context("failed to execute tmux kill-session")
235    }
236
237    pub fn capture_session(&self, session: &str) -> Result<SessionSnapshot> {
238        self.ensure_session_exists(session)?;
239
240        let windows = self.list_windows(session)?;
241        let active_window_name = windows
242            .iter()
243            .find(|window| window.active)
244            .or_else(|| windows.first())
245            .context("tmux session did not contain any windows")?;
246        let active_window_name = active_window_name.name.clone();
247
248        let mut captured_windows = Vec::with_capacity(windows.len());
249        let mut active_pane = None;
250        let mut active_path = None;
251
252        for window in windows {
253            let synchronize = self.window_synchronize(&window.id)?;
254            let panes = self.list_pane_records(&window.id)?;
255            let panes = infer_pane_layouts(panes);
256
257            if window.active {
258                let active = panes
259                    .iter()
260                    .enumerate()
261                    .find(|(_, pane)| pane.active)
262                    .or_else(|| panes.first().map(|pane| (0, pane)))
263                    .context("active tmux window did not contain any panes")?;
264                active_pane = Some(active.0);
265                active_path = Some(active.1.cwd.clone());
266            }
267
268            captured_windows.push(WindowSnapshot {
269                name: window.name,
270                synchronize,
271                active: window.active,
272                panes,
273            });
274        }
275
276        let active_path =
277            active_path.context("could not determine the active pane path for the tmux session")?;
278
279        Ok(SessionSnapshot {
280            session_name: session.to_owned(),
281            active_window: active_window_name,
282            active_pane: active_pane.unwrap_or(0),
283            active_path,
284            windows: captured_windows,
285        })
286    }
287
288    fn create_session_with_window(
289        &self,
290        session: &str,
291        window: &str,
292        directory: &Path,
293    ) -> Result<()> {
294        let directory = util::path_to_string(directory)?;
295        self.run_tmux([
296            "new-session",
297            "-d",
298            "-s",
299            session,
300            "-c",
301            &directory,
302            "-n",
303            window,
304        ])
305        .context("failed to execute tmux new-session")
306    }
307
308    fn new_window(&self, session: &str, window: &str, directory: &Path) -> Result<()> {
309        let directory = util::path_to_string(directory)?;
310        self.run_tmux(["new-window", "-t", session, "-n", window, "-c", &directory])
311            .context("failed to execute tmux new-window")
312    }
313
314    fn send_keys_to_target(&self, target: &str, command: &str) -> Result<()> {
315        self.run_tmux(["send-keys", "-t", target, command, "C-m"])
316            .context("failed to execute tmux send-keys")
317    }
318
319    fn split_window(&self, target: &str, layout: &PaneLayout, directory: &Path) -> Result<String> {
320        let directory = util::path_to_string(directory)?;
321        let mut args = vec![
322            "split-window".to_owned(),
323            "-t".to_owned(),
324            target.to_owned(),
325            "-P".to_owned(),
326            "-F".to_owned(),
327            "#{pane_id}".to_owned(),
328        ];
329
330        match layout.position {
331            PanePosition::Right | PanePosition::Left => args.push("-h".to_owned()),
332            PanePosition::Bottom | PanePosition::Top => args.push("-v".to_owned()),
333        }
334
335        match layout.position {
336            PanePosition::Left | PanePosition::Top => args.push("-b".to_owned()),
337            PanePosition::Right | PanePosition::Bottom => {}
338        }
339
340        if let Some(size) = &layout.size {
341            args.push("-l".to_owned());
342            args.push(size.clone());
343        }
344
345        args.push("-c".to_owned());
346        args.push(directory);
347
348        let output = self
349            .runner
350            .run_capture("tmux", &args)
351            .context("failed to execute tmux split-window")?;
352
353        if !output.status.success {
354            let stderr = String::from_utf8_lossy(&output.stderr);
355            bail!("tmux split-window failed: {}", stderr.trim());
356        }
357
358        let pane_id =
359            String::from_utf8(output.stdout).context("tmux split-window output was not utf-8")?;
360        Ok(pane_id.trim().to_owned())
361    }
362
363    fn select_layout(&self, target: &str, layout: &str) -> Result<()> {
364        self.run_tmux(["select-layout", "-t", target, layout])
365            .context("failed to execute tmux select-layout")
366    }
367
368    fn select_window(&self, session: &str, window: &str) -> Result<()> {
369        let target = format!("{session}:{window}");
370        self.run_tmux(["select-window", "-t", &target])
371            .context("failed to execute tmux select-window")
372    }
373
374    fn select_pane_target(&self, target: &str) -> Result<()> {
375        self.run_tmux(["select-pane", "-t", target])
376            .context("failed to execute tmux select-pane")
377    }
378
379    fn set_synchronize_panes(&self, session: &str, window: &str, enabled: bool) -> Result<()> {
380        let target = format!("{session}:{window}");
381        let value = if enabled { "on" } else { "off" };
382        self.run_tmux([
383            "set-window-option",
384            "-t",
385            &target,
386            "synchronize-panes",
387            value,
388        ])
389        .context("failed to execute tmux set-window-option")
390    }
391
392    fn configure_panes(
393        &self,
394        session: &str,
395        window: &str,
396        plan: &crate::templates::WindowPlan,
397    ) -> Result<()> {
398        let target = format!("{session}:{window}");
399        let pane_ids = self.list_panes(&target)?;
400        let first_pane_target = pane_ids
401            .first()
402            .cloned()
403            .context("tmux window did not contain an initial pane")?;
404
405        if plan.panes.is_empty() {
406            if let Some(pre_command) = &plan.pre_command {
407                self.send_keys_to_target(&first_pane_target, pre_command)?;
408            }
409            if let Some(command) = &plan.command {
410                self.send_keys_to_target(&first_pane_target, command)?;
411            }
412            if plan.synchronize {
413                self.set_synchronize_panes(session, window, true)?;
414            }
415            return Ok(());
416        }
417
418        if let Some(pre_command) = &plan.pre_command {
419            self.send_keys_to_target(&first_pane_target, pre_command)
420                .context("failed to execute tmux send-keys for first pane pre_command")?;
421        }
422        if let Some(command) = &plan.panes[0].command {
423            self.send_keys_to_target(&first_pane_target, command)
424                .context("failed to execute tmux send-keys for first pane")?;
425        }
426
427        for (pane_index, pane) in plan.panes.iter().enumerate().skip(1) {
428            let layout = pane.layout.as_ref().ok_or_else(|| {
429                anyhow::anyhow!(
430                    "pane {} in window \"{}\" is missing a layout",
431                    pane_index,
432                    window
433                )
434            })?;
435            let pane_target = self.split_window(&target, layout, &pane.cwd)?;
436            if let Some(pre_command) = &plan.pre_command {
437                self.send_keys_to_target(&pane_target, pre_command)
438                    .context("failed to execute tmux send-keys for split pane pre_command")?;
439            }
440            if let Some(command) = &pane.command {
441                self.send_keys_to_target(&pane_target, command)
442                    .context("failed to execute tmux send-keys for split pane")?;
443            }
444        }
445
446        if let Some(layout) = &plan.layout {
447            self.select_layout(&target, layout)?;
448        }
449
450        if plan.synchronize {
451            self.set_synchronize_panes(session, window, true)?;
452        }
453
454        Ok(())
455    }
456
457    fn list_panes(&self, target: &str) -> Result<Vec<String>> {
458        let output = self
459            .run_tmux_capture(["list-panes", "-t", target, "-F", "#{pane_id}"])
460            .context("failed to execute tmux list-panes")?;
461
462        if !output.status.success {
463            let stderr = String::from_utf8_lossy(&output.stderr);
464            bail!("tmux list-panes failed: {}", stderr.trim());
465        }
466
467        let stdout =
468            String::from_utf8(output.stdout).context("tmux list-panes output was not utf-8")?;
469        Ok(stdout
470            .lines()
471            .map(str::trim)
472            .filter(|line| !line.is_empty())
473            .map(ToOwned::to_owned)
474            .collect())
475    }
476
477    fn select_pane_by_offset(&self, session: &str, window: &str, pane_offset: usize) -> Result<()> {
478        let target = format!("{session}:{window}");
479        let panes = self.list_panes(&target)?;
480        let pane = panes.get(pane_offset).with_context(|| {
481            format!(
482                "startup pane offset {} was not found in window {}",
483                pane_offset, target
484            )
485        })?;
486        self.select_pane_target(pane)
487    }
488
489    fn run_tmux<const N: usize>(&self, args: [&str; N]) -> Result<()> {
490        let output = self.run_tmux_capture(args)?;
491
492        if output.status.success {
493            Ok(())
494        } else {
495            let stderr = String::from_utf8_lossy(&output.stderr);
496            bail!("{}", stderr.trim())
497        }
498    }
499
500    fn run_tmux_capture<const N: usize>(&self, args: [&str; N]) -> Result<CommandOutput> {
501        let args = args.into_iter().map(ToOwned::to_owned).collect::<Vec<_>>();
502        self.runner.run_capture("tmux", &args).map_err(Into::into)
503    }
504
505    fn list_windows(&self, session: &str) -> Result<Vec<WindowRecord>> {
506        let output = self
507            .run_tmux_capture([
508                "list-windows",
509                "-t",
510                session,
511                "-F",
512                "#{window_id}\t#{window_name}\t#{window_active}",
513            ])
514            .context("failed to execute tmux list-windows")?;
515
516        if !output.status.success {
517            let stderr = String::from_utf8_lossy(&output.stderr);
518            bail!("tmux list-windows failed: {}", stderr.trim());
519        }
520
521        let stdout =
522            String::from_utf8(output.stdout).context("tmux list-windows output was not utf-8")?;
523        stdout
524            .lines()
525            .filter(|line| !line.trim().is_empty())
526            .map(parse_window_record)
527            .collect()
528    }
529
530    fn window_synchronize(&self, window_id: &str) -> Result<bool> {
531        let output = self
532            .run_tmux_capture([
533                "show-window-options",
534                "-t",
535                window_id,
536                "-v",
537                "synchronize-panes",
538            ])
539            .context("failed to execute tmux show-window-options")?;
540
541        if !output.status.success {
542            let stderr = String::from_utf8_lossy(&output.stderr);
543            bail!("tmux show-window-options failed: {}", stderr.trim());
544        }
545
546        let stdout = String::from_utf8(output.stdout)
547            .context("tmux show-window-options output was not utf-8")?;
548        Ok(stdout.trim() == "on")
549    }
550
551    fn list_pane_records(&self, window_id: &str) -> Result<Vec<PaneRecord>> {
552        let output = self
553            .run_tmux_capture([
554                "list-panes",
555                "-t",
556                window_id,
557                "-F",
558                "#{pane_index}\t#{pane_current_path}\t#{pane_active}\t#{pane_left}\t#{pane_top}\t#{pane_width}\t#{pane_height}",
559            ])
560            .context("failed to execute tmux list-panes")?;
561
562        if !output.status.success {
563            let stderr = String::from_utf8_lossy(&output.stderr);
564            bail!("tmux list-panes failed: {}", stderr.trim());
565        }
566
567        let stdout =
568            String::from_utf8(output.stdout).context("tmux list-panes output was not utf-8")?;
569        let mut panes = stdout
570            .lines()
571            .filter(|line| !line.trim().is_empty())
572            .map(parse_pane_record)
573            .collect::<Result<Vec<_>>>()?;
574        panes.sort_by_key(|pane| pane.index);
575        Ok(panes)
576    }
577}
578
579fn parse_window_record(line: &str) -> Result<WindowRecord> {
580    let mut parts = line.splitn(3, '\t');
581    let id = parts.next().context("missing tmux window id")?.to_owned();
582    let name = parts.next().context("missing tmux window name")?.to_owned();
583    let active = match parts.next().context("missing tmux window active flag")? {
584        "1" => true,
585        "0" => false,
586        other => bail!("invalid tmux window active flag: {other}"),
587    };
588
589    Ok(WindowRecord { id, name, active })
590}
591
592fn parse_pane_record(line: &str) -> Result<PaneRecord> {
593    let mut parts = line.splitn(7, '\t');
594    let index = parts
595        .next()
596        .context("missing tmux pane index")?
597        .parse()
598        .context("tmux pane index was not a number")?;
599    let cwd = std::path::PathBuf::from(parts.next().context("missing tmux pane cwd")?);
600    let active = match parts.next().context("missing tmux pane active flag")? {
601        "1" => true,
602        "0" => false,
603        other => bail!("invalid tmux pane active flag: {other}"),
604    };
605    let left = parts
606        .next()
607        .context("missing tmux pane left coordinate")?
608        .parse()
609        .context("tmux pane left was not a number")?;
610    let top = parts
611        .next()
612        .context("missing tmux pane top coordinate")?
613        .parse()
614        .context("tmux pane top was not a number")?;
615    let width = parts
616        .next()
617        .context("missing tmux pane width")?
618        .parse()
619        .context("tmux pane width was not a number")?;
620    let height = parts
621        .next()
622        .context("missing tmux pane height")?
623        .parse()
624        .context("tmux pane height was not a number")?;
625
626    Ok(PaneRecord {
627        index,
628        cwd,
629        active,
630        left,
631        top,
632        width,
633        height,
634    })
635}
636
637fn infer_pane_layouts(panes: Vec<PaneRecord>) -> Vec<PaneSnapshot> {
638    let mut inferred = Vec::with_capacity(panes.len());
639
640    for pane in panes {
641        let layout = if inferred.is_empty() {
642            None
643        } else {
644            Some(PaneLayout {
645                position: infer_pane_position(&pane, &inferred),
646                size: None,
647            })
648        };
649
650        inferred.push(PaneSnapshot {
651            cwd: pane.cwd,
652            active: pane.active,
653            layout,
654        });
655    }
656
657    inferred
658}
659
660fn infer_pane_position(pane: &PaneRecord, previous: &[PaneSnapshot]) -> PanePosition {
661    let _ = previous;
662    if pane.left > 0 && pane.top == 0 {
663        PanePosition::Right
664    } else if pane.top > 0 && pane.left == 0 {
665        PanePosition::Bottom
666    } else if pane.left > 0 {
667        PanePosition::Right
668    } else if pane.top > 0 {
669        PanePosition::Bottom
670    } else {
671        PanePosition::Right
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use std::sync::Arc;
678    use std::sync::Mutex;
679
680    use crate::process::{CommandOutput, CommandStatus, FakeCommandRunner, IoMode};
681    use crate::templates::{PaneLayout, PanePlan, PanePosition, SessionPlan, WindowPlan};
682
683    use super::Tmux;
684
685    static TMUX_ENV_LOCK: Mutex<()> = Mutex::new(());
686
687    #[test]
688    fn outside_tmux_uses_inherited_stdio_for_attach() {
689        let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
690        let runner = Arc::new(FakeCommandRunner::new());
691        runner.push_inherit(Ok(CommandStatus {
692            success: true,
693            code: Some(0),
694        }));
695
696        unsafe {
697            std::env::remove_var("TMUX");
698        }
699
700        let tmux = Tmux::with_runner(runner.clone());
701        tmux.switch_or_attach("demo")
702            .expect("attach should succeed");
703
704        let recorded = runner.recorded();
705        assert_eq!(recorded.len(), 1);
706        assert_eq!(recorded[0].program, "tmux");
707        assert_eq!(recorded[0].args, vec!["attach-session", "-t", "demo"]);
708        assert_eq!(recorded[0].io_mode, IoMode::Inherit);
709    }
710
711    #[test]
712    fn outside_tmux_has_no_current_session() {
713        let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
714        let runner = Arc::new(FakeCommandRunner::new());
715
716        unsafe {
717            std::env::remove_var("TMUX");
718        }
719
720        let tmux = Tmux::with_runner(runner.clone());
721        assert_eq!(tmux.current_session().expect("query should succeed"), None);
722        assert!(runner.recorded().is_empty());
723    }
724
725    #[test]
726    fn inside_tmux_uses_switch_client_with_captured_io() {
727        let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
728        let runner = Arc::new(FakeCommandRunner::new());
729        runner.push_capture(Ok(CommandOutput {
730            status: CommandStatus {
731                success: true,
732                code: Some(0),
733            },
734            stdout: Vec::new(),
735            stderr: Vec::new(),
736        }));
737
738        unsafe {
739            std::env::set_var("TMUX", "/tmp/tmux-test,123,0");
740        }
741
742        let tmux = Tmux::with_runner(runner.clone());
743        tmux.switch_or_attach("demo")
744            .expect("switch-client should succeed");
745
746        let recorded = runner.recorded();
747        assert_eq!(recorded.len(), 1);
748        assert_eq!(recorded[0].program, "tmux");
749        assert_eq!(recorded[0].args, vec!["switch-client", "-t", "demo"]);
750        assert_eq!(recorded[0].io_mode, IoMode::Capture);
751
752        unsafe {
753            std::env::remove_var("TMUX");
754        }
755    }
756
757    #[test]
758    fn inside_tmux_reads_current_session() {
759        let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
760        let runner = Arc::new(FakeCommandRunner::new());
761        runner.push_capture(Ok(CommandOutput {
762            status: CommandStatus {
763                success: true,
764                code: Some(0),
765            },
766            stdout: b"demo\n".to_vec(),
767            stderr: Vec::new(),
768        }));
769
770        unsafe {
771            std::env::set_var("TMUX", "/tmp/tmux-test,123,0");
772        }
773
774        let tmux = Tmux::with_runner(runner.clone());
775        assert_eq!(
776            tmux.current_session()
777                .expect("query should succeed")
778                .as_deref(),
779            Some("demo")
780        );
781
782        let recorded = runner.recorded();
783        assert_eq!(recorded.len(), 1);
784        assert_eq!(
785            recorded[0].args,
786            vec!["display-message", "-p", "#{session_name}"]
787        );
788
789        unsafe {
790            std::env::remove_var("TMUX");
791        }
792    }
793
794    #[test]
795    fn session_plan_emits_expected_tmux_commands() {
796        let runner = Arc::new(FakeCommandRunner::new());
797        runner.push_capture(ok_capture(Vec::new()));
798        runner.push_capture(ok_capture(b"%1\n".to_vec()));
799        runner.push_capture(ok_capture(Vec::new()));
800        runner.push_capture(ok_capture(Vec::new()));
801        runner.push_capture(ok_capture(Vec::new()));
802        runner.push_capture(ok_capture(b"%2\n".to_vec()));
803        runner.push_capture(ok_capture(Vec::new()));
804        runner.push_capture(ok_capture(Vec::new()));
805        runner.push_capture(ok_capture(b"%3\n".to_vec()));
806        runner.push_capture(ok_capture(Vec::new()));
807        runner.push_capture(ok_capture(Vec::new()));
808        runner.push_capture(ok_capture(Vec::new()));
809        runner.push_capture(ok_capture(Vec::new()));
810        runner.push_capture(ok_capture(Vec::new()));
811        runner.push_capture(ok_capture(b"%1\n".to_vec()));
812        runner.push_capture(ok_capture(Vec::new()));
813
814        let tmux = Tmux::with_runner(runner.clone());
815        let plan = SessionPlan {
816            session_name: "demo".to_owned(),
817            startup_window: "editor".to_owned(),
818            startup_pane: 0,
819            windows: vec![
820                WindowPlan {
821                    name: "editor".to_owned(),
822                    cwd: "/tmp/demo".into(),
823                    pre_command: Some("source .venv/bin/activate".to_owned()),
824                    command: Some("nvim".to_owned()),
825                    layout: None,
826                    synchronize: false,
827                    panes: Vec::new(),
828                },
829                WindowPlan {
830                    name: "run".to_owned(),
831                    cwd: "/tmp/demo".into(),
832                    pre_command: Some("source .venv/bin/activate".to_owned()),
833                    command: None,
834                    layout: Some("main-horizontal".to_owned()),
835                    synchronize: true,
836                    panes: vec![
837                        PanePlan {
838                            layout: None,
839                            cwd: "/tmp/demo".into(),
840                            command: Some("cargo run".to_owned()),
841                        },
842                        PanePlan {
843                            layout: Some(PaneLayout {
844                                position: PanePosition::Right,
845                                size: None,
846                            }),
847                            cwd: "/tmp/demo".into(),
848                            command: Some("cargo test".to_owned()),
849                        },
850                    ],
851                },
852            ],
853        };
854
855        tmux.create_session_from_plan(&plan)
856            .expect("session plan should succeed");
857
858        let recorded = runner.recorded();
859        assert_eq!(recorded[0].args[..4], ["new-session", "-d", "-s", "demo"]);
860        assert_eq!(
861            recorded[1].args,
862            vec!["list-panes", "-t", "demo:editor", "-F", "#{pane_id}"]
863        );
864        assert_eq!(
865            recorded[2].args,
866            vec!["send-keys", "-t", "%1", "source .venv/bin/activate", "C-m"]
867        );
868        assert_eq!(
869            recorded[3].args,
870            vec!["send-keys", "-t", "%1", "nvim", "C-m"]
871        );
872        assert_eq!(
873            recorded[4].args,
874            vec!["new-window", "-t", "demo", "-n", "run", "-c", "/tmp/demo"]
875        );
876        assert_eq!(
877            recorded[5].args,
878            vec!["list-panes", "-t", "demo:run", "-F", "#{pane_id}"]
879        );
880        assert_eq!(
881            recorded[6].args,
882            vec!["send-keys", "-t", "%2", "source .venv/bin/activate", "C-m"]
883        );
884        assert_eq!(
885            recorded[7].args,
886            vec!["send-keys", "-t", "%2", "cargo run", "C-m"]
887        );
888        assert_eq!(
889            recorded[8].args,
890            vec![
891                "split-window",
892                "-t",
893                "demo:run",
894                "-P",
895                "-F",
896                "#{pane_id}",
897                "-h",
898                "-c",
899                "/tmp/demo"
900            ]
901        );
902        assert_eq!(
903            recorded[9].args,
904            vec!["send-keys", "-t", "%3", "source .venv/bin/activate", "C-m"]
905        );
906        assert_eq!(
907            recorded[10].args,
908            vec!["send-keys", "-t", "%3", "cargo test", "C-m"]
909        );
910        assert_eq!(
911            recorded[11].args,
912            vec!["select-layout", "-t", "demo:run", "main-horizontal"]
913        );
914        assert_eq!(
915            recorded[12].args,
916            vec![
917                "set-window-option",
918                "-t",
919                "demo:run",
920                "synchronize-panes",
921                "on"
922            ]
923        );
924        assert_eq!(
925            recorded[13].args,
926            vec!["select-window", "-t", "demo:editor"]
927        );
928        assert_eq!(
929            recorded[14].args,
930            vec!["list-panes", "-t", "demo:editor", "-F", "#{pane_id}"]
931        );
932        assert_eq!(recorded[15].args, vec!["select-pane", "-t", "%1"]);
933    }
934
935    #[test]
936    fn kill_session_uses_captured_tmux_command() {
937        let runner = Arc::new(FakeCommandRunner::new());
938        runner.push_capture(ok_capture(Vec::new()));
939
940        let tmux = Tmux::with_runner(runner.clone());
941        tmux.kill_session("demo")
942            .expect("kill-session should succeed");
943
944        let recorded = runner.recorded();
945        assert_eq!(recorded.len(), 1);
946        assert_eq!(recorded[0].program, "tmux");
947        assert_eq!(recorded[0].args, vec!["kill-session", "-t", "demo"]);
948        assert_eq!(recorded[0].io_mode, IoMode::Capture);
949    }
950
951    #[test]
952    fn capture_session_reads_windows_and_panes() {
953        let runner = Arc::new(FakeCommandRunner::new());
954        runner.push_capture(ok_capture(Vec::new()));
955        runner.push_capture(ok_capture(b"@1\teditor\t1\n@2\trun\t0\n".to_vec()));
956        runner.push_capture(ok_capture(b"off\n".to_vec()));
957        runner.push_capture(ok_capture(
958            b"0\t/tmp/demo\t1\t0\t0\t100\t40\n1\t/tmp/demo/server\t0\t50\t0\t50\t40\n".to_vec(),
959        ));
960        runner.push_capture(ok_capture(b"on\n".to_vec()));
961        runner.push_capture(ok_capture(b"0\t/tmp/demo\t1\t0\t0\t100\t40\n".to_vec()));
962
963        let tmux = Tmux::with_runner(runner);
964        let snapshot = tmux
965            .capture_session("demo")
966            .expect("capture should succeed");
967
968        assert_eq!(snapshot.session_name, "demo");
969        assert_eq!(snapshot.active_window, "editor");
970        assert_eq!(snapshot.active_pane, 0);
971        assert_eq!(snapshot.active_path, std::path::PathBuf::from("/tmp/demo"));
972        assert_eq!(snapshot.windows.len(), 2);
973        assert_eq!(snapshot.windows[0].name, "editor");
974        assert!(!snapshot.windows[0].synchronize);
975        assert_eq!(snapshot.windows[0].panes.len(), 2);
976        assert_eq!(
977            snapshot.windows[0].panes[1].layout,
978            Some(PaneLayout {
979                position: PanePosition::Right,
980                size: None,
981            })
982        );
983        assert!(snapshot.windows[1].synchronize);
984    }
985
986    #[test]
987    fn startup_pane_uses_zero_based_offset_not_tmux_base_index() {
988        let runner = Arc::new(FakeCommandRunner::new());
989        runner.push_capture(ok_capture(Vec::new()));
990        runner.push_capture(ok_capture(b"%10\n".to_vec()));
991        runner.push_capture(ok_capture(Vec::new()));
992        runner.push_capture(ok_capture(b"%11\n".to_vec()));
993        runner.push_capture(ok_capture(Vec::new()));
994        runner.push_capture(ok_capture(Vec::new()));
995        runner.push_capture(ok_capture(b"%10\n%11\n".to_vec()));
996        runner.push_capture(ok_capture(Vec::new()));
997
998        let tmux = Tmux::with_runner(runner.clone());
999        let plan = SessionPlan {
1000            session_name: "demo".to_owned(),
1001            startup_window: "main".to_owned(),
1002            startup_pane: 1,
1003            windows: vec![WindowPlan {
1004                name: "main".to_owned(),
1005                cwd: "/tmp/demo".into(),
1006                pre_command: None,
1007                command: None,
1008                layout: None,
1009                synchronize: false,
1010                panes: vec![
1011                    PanePlan {
1012                        layout: None,
1013                        cwd: "/tmp/demo".into(),
1014                        command: Some("shell".to_owned()),
1015                    },
1016                    PanePlan {
1017                        layout: Some(PaneLayout {
1018                            position: PanePosition::Right,
1019                            size: None,
1020                        }),
1021                        cwd: "/tmp/demo".into(),
1022                        command: Some("tests".to_owned()),
1023                    },
1024                ],
1025            }],
1026        };
1027
1028        tmux.create_session_from_plan(&plan)
1029            .expect("session plan should succeed");
1030
1031        let recorded = runner.recorded();
1032        assert_eq!(
1033            recorded[6].args,
1034            vec!["list-panes", "-t", "demo:main", "-F", "#{pane_id}"]
1035        );
1036        assert_eq!(recorded[7].args, vec!["select-pane", "-t", "%11"]);
1037    }
1038
1039    fn ok_capture(stdout: Vec<u8>) -> std::io::Result<CommandOutput> {
1040        Ok(CommandOutput {
1041            status: CommandStatus {
1042                success: true,
1043                code: Some(0),
1044            },
1045            stdout,
1046            stderr: Vec::new(),
1047        })
1048    }
1049}