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        let mut previous_window = first_window.name.as_str();
199        for window in plan.windows.iter().skip(1) {
200            self.new_window_after(
201                &plan.session_name,
202                previous_window,
203                &window.name,
204                &window.cwd,
205            )?;
206            self.configure_panes(&plan.session_name, &window.name, window)?;
207            previous_window = &window.name;
208        }
209
210        self.select_window(&plan.session_name, &plan.startup_window)?;
211        self.select_pane_by_offset(&plan.session_name, &plan.startup_window, plan.startup_pane)?;
212        Ok(())
213    }
214
215    pub fn switch_or_attach(&self, session: &str) -> Result<()> {
216        if util::inside_tmux() {
217            self.run_tmux(["switch-client", "-t", session])
218                .context("failed to execute tmux switch-client")
219        } else {
220            let args = vec![
221                "attach-session".to_owned(),
222                "-t".to_owned(),
223                session.to_owned(),
224            ];
225
226            let status = self
227                .runner
228                .run_inherit("tmux", &args)
229                .context("failed to execute tmux attach-session")?;
230
231            if status.success {
232                Ok(())
233            } else {
234                bail!("tmux attach-session failed with status {:?}", status.code)
235            }
236        }
237    }
238
239    pub fn kill_session(&self, session: &str) -> Result<()> {
240        self.run_tmux(["kill-session", "-t", session])
241            .context("failed to execute tmux kill-session")
242    }
243
244    pub fn capture_session(&self, session: &str) -> Result<SessionSnapshot> {
245        self.ensure_session_exists(session)?;
246
247        let windows = self.list_windows(session)?;
248        let active_window_name = windows
249            .iter()
250            .find(|window| window.active)
251            .or_else(|| windows.first())
252            .context("tmux session did not contain any windows")?;
253        let active_window_name = active_window_name.name.clone();
254
255        let mut captured_windows = Vec::with_capacity(windows.len());
256        let mut active_pane = None;
257        let mut active_path = None;
258
259        for window in windows {
260            let synchronize = self.window_synchronize(&window.id)?;
261            let panes = self.list_pane_records(&window.id)?;
262            let panes = infer_pane_layouts(panes);
263
264            if window.active {
265                let active = panes
266                    .iter()
267                    .enumerate()
268                    .find(|(_, pane)| pane.active)
269                    .or_else(|| panes.first().map(|pane| (0, pane)))
270                    .context("active tmux window did not contain any panes")?;
271                active_pane = Some(active.0);
272                active_path = Some(active.1.cwd.clone());
273            }
274
275            captured_windows.push(WindowSnapshot {
276                name: window.name,
277                synchronize,
278                active: window.active,
279                panes,
280            });
281        }
282
283        let active_path =
284            active_path.context("could not determine the active pane path for the tmux session")?;
285
286        Ok(SessionSnapshot {
287            session_name: session.to_owned(),
288            active_window: active_window_name,
289            active_pane: active_pane.unwrap_or(0),
290            active_path,
291            windows: captured_windows,
292        })
293    }
294
295    fn create_session_with_window(
296        &self,
297        session: &str,
298        window: &str,
299        directory: &Path,
300    ) -> Result<()> {
301        let directory = util::path_to_string(directory)?;
302        self.run_tmux([
303            "new-session",
304            "-d",
305            "-s",
306            session,
307            "-c",
308            &directory,
309            "-n",
310            window,
311        ])
312        .context("failed to execute tmux new-session")
313    }
314
315    fn new_window_after(
316        &self,
317        session: &str,
318        after_window: &str,
319        window: &str,
320        directory: &Path,
321    ) -> Result<()> {
322        let directory = util::path_to_string(directory)?;
323        let target = format!("{session}:{after_window}");
324        self.run_tmux([
325            "new-window",
326            "-a",
327            "-t",
328            &target,
329            "-n",
330            window,
331            "-c",
332            &directory,
333        ])
334        .context("failed to execute tmux new-window")
335    }
336
337    fn send_keys_to_target(&self, target: &str, command: &str) -> Result<()> {
338        self.run_tmux(["send-keys", "-t", target, command, "C-m"])
339            .context("failed to execute tmux send-keys")
340    }
341
342    fn split_window(&self, target: &str, layout: &PaneLayout, directory: &Path) -> Result<String> {
343        let directory = util::path_to_string(directory)?;
344        let mut args = vec![
345            "split-window".to_owned(),
346            "-t".to_owned(),
347            target.to_owned(),
348            "-P".to_owned(),
349            "-F".to_owned(),
350            "#{pane_id}".to_owned(),
351        ];
352
353        match layout.position {
354            PanePosition::Right | PanePosition::Left => args.push("-h".to_owned()),
355            PanePosition::Bottom | PanePosition::Top => args.push("-v".to_owned()),
356        }
357
358        match layout.position {
359            PanePosition::Left | PanePosition::Top => args.push("-b".to_owned()),
360            PanePosition::Right | PanePosition::Bottom => {}
361        }
362
363        if let Some(size) = &layout.size {
364            args.push("-l".to_owned());
365            args.push(size.clone());
366        }
367
368        args.push("-c".to_owned());
369        args.push(directory);
370
371        let output = self
372            .runner
373            .run_capture("tmux", &args)
374            .context("failed to execute tmux split-window")?;
375
376        if !output.status.success {
377            let stderr = String::from_utf8_lossy(&output.stderr);
378            bail!("tmux split-window failed: {}", stderr.trim());
379        }
380
381        let pane_id =
382            String::from_utf8(output.stdout).context("tmux split-window output was not utf-8")?;
383        Ok(pane_id.trim().to_owned())
384    }
385
386    fn select_layout(&self, target: &str, layout: &str) -> Result<()> {
387        self.run_tmux(["select-layout", "-t", target, layout])
388            .context("failed to execute tmux select-layout")
389    }
390
391    fn select_window(&self, session: &str, window: &str) -> Result<()> {
392        let target = format!("{session}:{window}");
393        self.run_tmux(["select-window", "-t", &target])
394            .context("failed to execute tmux select-window")
395    }
396
397    fn select_pane_target(&self, target: &str) -> Result<()> {
398        self.run_tmux(["select-pane", "-t", target])
399            .context("failed to execute tmux select-pane")
400    }
401
402    fn set_synchronize_panes(&self, session: &str, window: &str, enabled: bool) -> Result<()> {
403        let target = format!("{session}:{window}");
404        let value = if enabled { "on" } else { "off" };
405        self.run_tmux([
406            "set-window-option",
407            "-t",
408            &target,
409            "synchronize-panes",
410            value,
411        ])
412        .context("failed to execute tmux set-window-option")
413    }
414
415    fn zoom_pane(&self, target: &str) -> Result<()> {
416        self.run_tmux(["resize-pane", "-Z", "-t", target])
417            .context("failed to execute tmux resize-pane -Z")
418    }
419
420    fn configure_panes(
421        &self,
422        session: &str,
423        window: &str,
424        plan: &crate::templates::WindowPlan,
425    ) -> Result<()> {
426        let target = format!("{session}:{window}");
427        let pane_ids = self.list_panes(&target)?;
428        let first_pane_target = pane_ids
429            .first()
430            .cloned()
431            .context("tmux window did not contain an initial pane")?;
432        let mut zoom_target = if plan.panes.is_empty() {
433            None
434        } else if plan.panes[0].zoom {
435            Some(first_pane_target.clone())
436        } else {
437            None
438        };
439
440        if plan.panes.is_empty() {
441            if let Some(pre_command) = &plan.pre_command {
442                self.send_keys_to_target(&first_pane_target, pre_command)?;
443            }
444            if let Some(command) = &plan.command {
445                self.send_keys_to_target(&first_pane_target, command)?;
446            }
447            if plan.synchronize {
448                self.set_synchronize_panes(session, window, true)?;
449            }
450            if let Some(target) = zoom_target.as_deref() {
451                self.zoom_pane(target)?;
452            }
453            return Ok(());
454        }
455
456        if let Some(pre_command) = &plan.pre_command {
457            self.send_keys_to_target(&first_pane_target, pre_command)
458                .context("failed to execute tmux send-keys for first pane pre_command")?;
459        }
460        if let Some(command) = &plan.panes[0].command {
461            self.send_keys_to_target(&first_pane_target, command)
462                .context("failed to execute tmux send-keys for first pane")?;
463        }
464
465        for (pane_index, pane) in plan.panes.iter().enumerate().skip(1) {
466            let layout = pane.layout.as_ref().ok_or_else(|| {
467                anyhow::anyhow!(
468                    "pane {} in window \"{}\" is missing a layout",
469                    pane_index,
470                    window
471                )
472            })?;
473            let pane_target = self.split_window(&target, layout, &pane.cwd)?;
474            if let Some(pre_command) = &plan.pre_command {
475                self.send_keys_to_target(&pane_target, pre_command)
476                    .context("failed to execute tmux send-keys for split pane pre_command")?;
477            }
478            if let Some(command) = &pane.command {
479                self.send_keys_to_target(&pane_target, command)
480                    .context("failed to execute tmux send-keys for split pane")?;
481            }
482            if pane.zoom {
483                zoom_target = Some(pane_target.clone());
484            }
485        }
486
487        if let Some(layout) = &plan.layout {
488            self.select_layout(&target, layout)?;
489        }
490
491        if plan.synchronize {
492            self.set_synchronize_panes(session, window, true)?;
493        }
494
495        if let Some(target) = zoom_target.as_deref() {
496            self.zoom_pane(target)?;
497        }
498
499        Ok(())
500    }
501
502    fn list_panes(&self, target: &str) -> Result<Vec<String>> {
503        let output = self
504            .run_tmux_capture(["list-panes", "-t", target, "-F", "#{pane_id}"])
505            .context("failed to execute tmux list-panes")?;
506
507        if !output.status.success {
508            let stderr = String::from_utf8_lossy(&output.stderr);
509            bail!("tmux list-panes failed: {}", stderr.trim());
510        }
511
512        let stdout =
513            String::from_utf8(output.stdout).context("tmux list-panes output was not utf-8")?;
514        Ok(stdout
515            .lines()
516            .map(str::trim)
517            .filter(|line| !line.is_empty())
518            .map(ToOwned::to_owned)
519            .collect())
520    }
521
522    fn select_pane_by_offset(&self, session: &str, window: &str, pane_offset: usize) -> Result<()> {
523        let target = format!("{session}:{window}");
524        let panes = self.list_panes(&target)?;
525        let pane = panes.get(pane_offset).with_context(|| {
526            format!(
527                "startup pane offset {} was not found in window {}",
528                pane_offset, target
529            )
530        })?;
531        self.select_pane_target(pane)
532    }
533
534    fn run_tmux<const N: usize>(&self, args: [&str; N]) -> Result<()> {
535        let output = self.run_tmux_capture(args)?;
536
537        if output.status.success {
538            Ok(())
539        } else {
540            let stderr = String::from_utf8_lossy(&output.stderr);
541            bail!("{}", stderr.trim())
542        }
543    }
544
545    fn run_tmux_capture<const N: usize>(&self, args: [&str; N]) -> Result<CommandOutput> {
546        let args = args.into_iter().map(ToOwned::to_owned).collect::<Vec<_>>();
547        self.runner.run_capture("tmux", &args).map_err(Into::into)
548    }
549
550    fn list_windows(&self, session: &str) -> Result<Vec<WindowRecord>> {
551        let output = self
552            .run_tmux_capture([
553                "list-windows",
554                "-t",
555                session,
556                "-F",
557                "#{window_id}\t#{window_name}\t#{window_active}",
558            ])
559            .context("failed to execute tmux list-windows")?;
560
561        if !output.status.success {
562            let stderr = String::from_utf8_lossy(&output.stderr);
563            bail!("tmux list-windows failed: {}", stderr.trim());
564        }
565
566        let stdout =
567            String::from_utf8(output.stdout).context("tmux list-windows output was not utf-8")?;
568        stdout
569            .lines()
570            .filter(|line| !line.trim().is_empty())
571            .map(parse_window_record)
572            .collect()
573    }
574
575    fn window_synchronize(&self, window_id: &str) -> Result<bool> {
576        let output = self
577            .run_tmux_capture([
578                "show-window-options",
579                "-t",
580                window_id,
581                "-v",
582                "synchronize-panes",
583            ])
584            .context("failed to execute tmux show-window-options")?;
585
586        if !output.status.success {
587            let stderr = String::from_utf8_lossy(&output.stderr);
588            bail!("tmux show-window-options failed: {}", stderr.trim());
589        }
590
591        let stdout = String::from_utf8(output.stdout)
592            .context("tmux show-window-options output was not utf-8")?;
593        Ok(stdout.trim() == "on")
594    }
595
596    fn list_pane_records(&self, window_id: &str) -> Result<Vec<PaneRecord>> {
597        let output = self
598            .run_tmux_capture([
599                "list-panes",
600                "-t",
601                window_id,
602                "-F",
603                "#{pane_index}\t#{pane_current_path}\t#{pane_active}\t#{pane_left}\t#{pane_top}\t#{pane_width}\t#{pane_height}",
604            ])
605            .context("failed to execute tmux list-panes")?;
606
607        if !output.status.success {
608            let stderr = String::from_utf8_lossy(&output.stderr);
609            bail!("tmux list-panes failed: {}", stderr.trim());
610        }
611
612        let stdout =
613            String::from_utf8(output.stdout).context("tmux list-panes output was not utf-8")?;
614        let mut panes = stdout
615            .lines()
616            .filter(|line| !line.trim().is_empty())
617            .map(parse_pane_record)
618            .collect::<Result<Vec<_>>>()?;
619        panes.sort_by_key(|pane| pane.index);
620        Ok(panes)
621    }
622}
623
624fn parse_window_record(line: &str) -> Result<WindowRecord> {
625    let mut parts = line.splitn(3, '\t');
626    let id = parts.next().context("missing tmux window id")?.to_owned();
627    let name = parts.next().context("missing tmux window name")?.to_owned();
628    let active = match parts.next().context("missing tmux window active flag")? {
629        "1" => true,
630        "0" => false,
631        other => bail!("invalid tmux window active flag: {other}"),
632    };
633
634    Ok(WindowRecord { id, name, active })
635}
636
637fn parse_pane_record(line: &str) -> Result<PaneRecord> {
638    let mut parts = line.splitn(7, '\t');
639    let index = parts
640        .next()
641        .context("missing tmux pane index")?
642        .parse()
643        .context("tmux pane index was not a number")?;
644    let cwd = std::path::PathBuf::from(parts.next().context("missing tmux pane cwd")?);
645    let active = match parts.next().context("missing tmux pane active flag")? {
646        "1" => true,
647        "0" => false,
648        other => bail!("invalid tmux pane active flag: {other}"),
649    };
650    let left = parts
651        .next()
652        .context("missing tmux pane left coordinate")?
653        .parse()
654        .context("tmux pane left was not a number")?;
655    let top = parts
656        .next()
657        .context("missing tmux pane top coordinate")?
658        .parse()
659        .context("tmux pane top was not a number")?;
660    let width = parts
661        .next()
662        .context("missing tmux pane width")?
663        .parse()
664        .context("tmux pane width was not a number")?;
665    let height = parts
666        .next()
667        .context("missing tmux pane height")?
668        .parse()
669        .context("tmux pane height was not a number")?;
670
671    Ok(PaneRecord {
672        index,
673        cwd,
674        active,
675        left,
676        top,
677        width,
678        height,
679    })
680}
681
682fn infer_pane_layouts(panes: Vec<PaneRecord>) -> Vec<PaneSnapshot> {
683    let mut inferred = Vec::with_capacity(panes.len());
684
685    for pane in panes {
686        let layout = if inferred.is_empty() {
687            None
688        } else {
689            Some(PaneLayout {
690                position: infer_pane_position(&pane, &inferred),
691                size: None,
692            })
693        };
694
695        inferred.push(PaneSnapshot {
696            cwd: pane.cwd,
697            active: pane.active,
698            layout,
699        });
700    }
701
702    inferred
703}
704
705fn infer_pane_position(pane: &PaneRecord, previous: &[PaneSnapshot]) -> PanePosition {
706    let _ = previous;
707    if pane.left > 0 && pane.top == 0 {
708        PanePosition::Right
709    } else if pane.top > 0 && pane.left == 0 {
710        PanePosition::Bottom
711    } else if pane.left > 0 {
712        PanePosition::Right
713    } else if pane.top > 0 {
714        PanePosition::Bottom
715    } else {
716        PanePosition::Right
717    }
718}
719
720#[cfg(test)]
721mod tests {
722    use std::sync::Arc;
723    use std::sync::Mutex;
724
725    use crate::process::{CommandOutput, CommandStatus, FakeCommandRunner, IoMode};
726    use crate::templates::{PaneLayout, PanePlan, PanePosition, SessionPlan, WindowPlan};
727
728    use super::Tmux;
729
730    static TMUX_ENV_LOCK: Mutex<()> = Mutex::new(());
731
732    #[test]
733    fn outside_tmux_uses_inherited_stdio_for_attach() {
734        let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
735        let runner = Arc::new(FakeCommandRunner::new());
736        runner.push_inherit(Ok(CommandStatus {
737            success: true,
738            code: Some(0),
739        }));
740
741        unsafe {
742            std::env::remove_var("TMUX");
743        }
744
745        let tmux = Tmux::with_runner(runner.clone());
746        tmux.switch_or_attach("demo")
747            .expect("attach should succeed");
748
749        let recorded = runner.recorded();
750        assert_eq!(recorded.len(), 1);
751        assert_eq!(recorded[0].program, "tmux");
752        assert_eq!(recorded[0].args, vec!["attach-session", "-t", "demo"]);
753        assert_eq!(recorded[0].io_mode, IoMode::Inherit);
754    }
755
756    #[test]
757    fn outside_tmux_has_no_current_session() {
758        let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
759        let runner = Arc::new(FakeCommandRunner::new());
760
761        unsafe {
762            std::env::remove_var("TMUX");
763        }
764
765        let tmux = Tmux::with_runner(runner.clone());
766        assert_eq!(tmux.current_session().expect("query should succeed"), None);
767        assert!(runner.recorded().is_empty());
768    }
769
770    #[test]
771    fn inside_tmux_uses_switch_client_with_captured_io() {
772        let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
773        let runner = Arc::new(FakeCommandRunner::new());
774        runner.push_capture(Ok(CommandOutput {
775            status: CommandStatus {
776                success: true,
777                code: Some(0),
778            },
779            stdout: Vec::new(),
780            stderr: Vec::new(),
781        }));
782
783        unsafe {
784            std::env::set_var("TMUX", "/tmp/tmux-test,123,0");
785        }
786
787        let tmux = Tmux::with_runner(runner.clone());
788        tmux.switch_or_attach("demo")
789            .expect("switch-client should succeed");
790
791        let recorded = runner.recorded();
792        assert_eq!(recorded.len(), 1);
793        assert_eq!(recorded[0].program, "tmux");
794        assert_eq!(recorded[0].args, vec!["switch-client", "-t", "demo"]);
795        assert_eq!(recorded[0].io_mode, IoMode::Capture);
796
797        unsafe {
798            std::env::remove_var("TMUX");
799        }
800    }
801
802    #[test]
803    fn inside_tmux_reads_current_session() {
804        let _guard = TMUX_ENV_LOCK.lock().expect("tmux env lock should work");
805        let runner = Arc::new(FakeCommandRunner::new());
806        runner.push_capture(Ok(CommandOutput {
807            status: CommandStatus {
808                success: true,
809                code: Some(0),
810            },
811            stdout: b"demo\n".to_vec(),
812            stderr: Vec::new(),
813        }));
814
815        unsafe {
816            std::env::set_var("TMUX", "/tmp/tmux-test,123,0");
817        }
818
819        let tmux = Tmux::with_runner(runner.clone());
820        assert_eq!(
821            tmux.current_session()
822                .expect("query should succeed")
823                .as_deref(),
824            Some("demo")
825        );
826
827        let recorded = runner.recorded();
828        assert_eq!(recorded.len(), 1);
829        assert_eq!(
830            recorded[0].args,
831            vec!["display-message", "-p", "#{session_name}"]
832        );
833
834        unsafe {
835            std::env::remove_var("TMUX");
836        }
837    }
838
839    #[test]
840    fn session_plan_emits_expected_tmux_commands() {
841        let runner = Arc::new(FakeCommandRunner::new());
842        runner.push_capture(ok_capture(Vec::new()));
843        runner.push_capture(ok_capture(b"%1\n".to_vec()));
844        runner.push_capture(ok_capture(Vec::new()));
845        runner.push_capture(ok_capture(Vec::new()));
846        runner.push_capture(ok_capture(Vec::new()));
847        runner.push_capture(ok_capture(b"%2\n".to_vec()));
848        runner.push_capture(ok_capture(Vec::new()));
849        runner.push_capture(ok_capture(Vec::new()));
850        runner.push_capture(ok_capture(b"%3\n".to_vec()));
851        runner.push_capture(ok_capture(Vec::new()));
852        runner.push_capture(ok_capture(Vec::new()));
853        runner.push_capture(ok_capture(Vec::new()));
854        runner.push_capture(ok_capture(Vec::new()));
855        runner.push_capture(ok_capture(Vec::new()));
856        runner.push_capture(ok_capture(b"%1\n".to_vec()));
857        runner.push_capture(ok_capture(Vec::new()));
858
859        let tmux = Tmux::with_runner(runner.clone());
860        let plan = SessionPlan {
861            session_name: "demo".to_owned(),
862            startup_window: "editor".to_owned(),
863            startup_pane: 0,
864            windows: vec![
865                WindowPlan {
866                    name: "editor".to_owned(),
867                    cwd: "/tmp/demo".into(),
868                    pre_command: Some("source .venv/bin/activate".to_owned()),
869                    command: Some("nvim".to_owned()),
870                    layout: None,
871                    synchronize: false,
872                    panes: Vec::new(),
873                },
874                WindowPlan {
875                    name: "run".to_owned(),
876                    cwd: "/tmp/demo".into(),
877                    pre_command: Some("source .venv/bin/activate".to_owned()),
878                    command: None,
879                    layout: Some("main-horizontal".to_owned()),
880                    synchronize: true,
881                    panes: vec![
882                        PanePlan {
883                            layout: None,
884                            cwd: "/tmp/demo".into(),
885                            command: Some("cargo run".to_owned()),
886                            zoom: false,
887                        },
888                        PanePlan {
889                            layout: Some(PaneLayout {
890                                position: PanePosition::Right,
891                                size: None,
892                            }),
893                            cwd: "/tmp/demo".into(),
894                            command: Some("cargo test".to_owned()),
895                            zoom: false,
896                        },
897                    ],
898                },
899            ],
900        };
901
902        tmux.create_session_from_plan(&plan)
903            .expect("session plan should succeed");
904
905        let recorded = runner.recorded();
906        assert_eq!(recorded[0].args[..4], ["new-session", "-d", "-s", "demo"]);
907        assert_eq!(
908            recorded[1].args,
909            vec!["list-panes", "-t", "demo:editor", "-F", "#{pane_id}"]
910        );
911        assert_eq!(
912            recorded[2].args,
913            vec!["send-keys", "-t", "%1", "source .venv/bin/activate", "C-m"]
914        );
915        assert_eq!(
916            recorded[3].args,
917            vec!["send-keys", "-t", "%1", "nvim", "C-m"]
918        );
919        assert_eq!(
920            recorded[4].args,
921            vec![
922                "new-window",
923                "-a",
924                "-t",
925                "demo:editor",
926                "-n",
927                "run",
928                "-c",
929                "/tmp/demo"
930            ]
931        );
932        assert_eq!(
933            recorded[5].args,
934            vec!["list-panes", "-t", "demo:run", "-F", "#{pane_id}"]
935        );
936        assert_eq!(
937            recorded[6].args,
938            vec!["send-keys", "-t", "%2", "source .venv/bin/activate", "C-m"]
939        );
940        assert_eq!(
941            recorded[7].args,
942            vec!["send-keys", "-t", "%2", "cargo run", "C-m"]
943        );
944        assert_eq!(
945            recorded[8].args,
946            vec![
947                "split-window",
948                "-t",
949                "demo:run",
950                "-P",
951                "-F",
952                "#{pane_id}",
953                "-h",
954                "-c",
955                "/tmp/demo"
956            ]
957        );
958        assert_eq!(
959            recorded[9].args,
960            vec!["send-keys", "-t", "%3", "source .venv/bin/activate", "C-m"]
961        );
962        assert_eq!(
963            recorded[10].args,
964            vec!["send-keys", "-t", "%3", "cargo test", "C-m"]
965        );
966        assert_eq!(
967            recorded[11].args,
968            vec!["select-layout", "-t", "demo:run", "main-horizontal"]
969        );
970        assert_eq!(
971            recorded[12].args,
972            vec![
973                "set-window-option",
974                "-t",
975                "demo:run",
976                "synchronize-panes",
977                "on"
978            ]
979        );
980        assert_eq!(
981            recorded[13].args,
982            vec!["select-window", "-t", "demo:editor"]
983        );
984        assert_eq!(
985            recorded[14].args,
986            vec!["list-panes", "-t", "demo:editor", "-F", "#{pane_id}"]
987        );
988        assert_eq!(recorded[15].args, vec!["select-pane", "-t", "%1"]);
989    }
990
991    #[test]
992    fn kill_session_uses_captured_tmux_command() {
993        let runner = Arc::new(FakeCommandRunner::new());
994        runner.push_capture(ok_capture(Vec::new()));
995
996        let tmux = Tmux::with_runner(runner.clone());
997        tmux.kill_session("demo")
998            .expect("kill-session should succeed");
999
1000        let recorded = runner.recorded();
1001        assert_eq!(recorded.len(), 1);
1002        assert_eq!(recorded[0].program, "tmux");
1003        assert_eq!(recorded[0].args, vec!["kill-session", "-t", "demo"]);
1004        assert_eq!(recorded[0].io_mode, IoMode::Capture);
1005    }
1006
1007    #[test]
1008    fn capture_session_reads_windows_and_panes() {
1009        let runner = Arc::new(FakeCommandRunner::new());
1010        runner.push_capture(ok_capture(Vec::new()));
1011        runner.push_capture(ok_capture(b"@1\teditor\t1\n@2\trun\t0\n".to_vec()));
1012        runner.push_capture(ok_capture(b"off\n".to_vec()));
1013        runner.push_capture(ok_capture(
1014            b"0\t/tmp/demo\t1\t0\t0\t100\t40\n1\t/tmp/demo/server\t0\t50\t0\t50\t40\n".to_vec(),
1015        ));
1016        runner.push_capture(ok_capture(b"on\n".to_vec()));
1017        runner.push_capture(ok_capture(b"0\t/tmp/demo\t1\t0\t0\t100\t40\n".to_vec()));
1018
1019        let tmux = Tmux::with_runner(runner);
1020        let snapshot = tmux
1021            .capture_session("demo")
1022            .expect("capture should succeed");
1023
1024        assert_eq!(snapshot.session_name, "demo");
1025        assert_eq!(snapshot.active_window, "editor");
1026        assert_eq!(snapshot.active_pane, 0);
1027        assert_eq!(snapshot.active_path, std::path::PathBuf::from("/tmp/demo"));
1028        assert_eq!(snapshot.windows.len(), 2);
1029        assert_eq!(snapshot.windows[0].name, "editor");
1030        assert!(!snapshot.windows[0].synchronize);
1031        assert_eq!(snapshot.windows[0].panes.len(), 2);
1032        assert_eq!(
1033            snapshot.windows[0].panes[1].layout,
1034            Some(PaneLayout {
1035                position: PanePosition::Right,
1036                size: None,
1037            })
1038        );
1039        assert!(snapshot.windows[1].synchronize);
1040    }
1041
1042    #[test]
1043    fn startup_pane_uses_zero_based_offset_not_tmux_base_index() {
1044        let runner = Arc::new(FakeCommandRunner::new());
1045        runner.push_capture(ok_capture(Vec::new()));
1046        runner.push_capture(ok_capture(b"%10\n".to_vec()));
1047        runner.push_capture(ok_capture(Vec::new()));
1048        runner.push_capture(ok_capture(b"%11\n".to_vec()));
1049        runner.push_capture(ok_capture(Vec::new()));
1050        runner.push_capture(ok_capture(Vec::new()));
1051        runner.push_capture(ok_capture(b"%10\n%11\n".to_vec()));
1052        runner.push_capture(ok_capture(Vec::new()));
1053
1054        let tmux = Tmux::with_runner(runner.clone());
1055        let plan = SessionPlan {
1056            session_name: "demo".to_owned(),
1057            startup_window: "main".to_owned(),
1058            startup_pane: 1,
1059            windows: vec![WindowPlan {
1060                name: "main".to_owned(),
1061                cwd: "/tmp/demo".into(),
1062                pre_command: None,
1063                command: None,
1064                layout: None,
1065                synchronize: false,
1066                panes: vec![
1067                    PanePlan {
1068                        layout: None,
1069                        cwd: "/tmp/demo".into(),
1070                        command: Some("shell".to_owned()),
1071                        zoom: false,
1072                    },
1073                    PanePlan {
1074                        layout: Some(PaneLayout {
1075                            position: PanePosition::Right,
1076                            size: None,
1077                        }),
1078                        cwd: "/tmp/demo".into(),
1079                        command: Some("tests".to_owned()),
1080                        zoom: false,
1081                    },
1082                ],
1083            }],
1084        };
1085
1086        tmux.create_session_from_plan(&plan)
1087            .expect("session plan should succeed");
1088
1089        let recorded = runner.recorded();
1090        assert_eq!(
1091            recorded[6].args,
1092            vec!["list-panes", "-t", "demo:main", "-F", "#{pane_id}"]
1093        );
1094        assert_eq!(recorded[7].args, vec!["select-pane", "-t", "%11"]);
1095    }
1096
1097    #[test]
1098    fn zoomed_pane_emits_resize_pane_command() {
1099        let runner = Arc::new(FakeCommandRunner::new());
1100        runner.push_capture(ok_capture(Vec::new()));
1101        runner.push_capture(ok_capture(b"%20\n".to_vec()));
1102        runner.push_capture(ok_capture(Vec::new()));
1103        runner.push_capture(ok_capture(b"%21\n".to_vec()));
1104        runner.push_capture(ok_capture(Vec::new()));
1105        runner.push_capture(ok_capture(Vec::new()));
1106        runner.push_capture(ok_capture(Vec::new()));
1107        runner.push_capture(ok_capture(b"%20\n%21\n".to_vec()));
1108        runner.push_capture(ok_capture(Vec::new()));
1109
1110        let tmux = Tmux::with_runner(runner.clone());
1111        let plan = SessionPlan {
1112            session_name: "demo".to_owned(),
1113            startup_window: "main".to_owned(),
1114            startup_pane: 0,
1115            windows: vec![WindowPlan {
1116                name: "main".to_owned(),
1117                cwd: "/tmp/demo".into(),
1118                pre_command: None,
1119                command: None,
1120                layout: None,
1121                synchronize: false,
1122                panes: vec![
1123                    PanePlan {
1124                        layout: None,
1125                        cwd: "/tmp/demo".into(),
1126                        command: Some("shell".to_owned()),
1127                        zoom: false,
1128                    },
1129                    PanePlan {
1130                        layout: Some(PaneLayout {
1131                            position: PanePosition::Right,
1132                            size: None,
1133                        }),
1134                        cwd: "/tmp/demo".into(),
1135                        command: Some("tests".to_owned()),
1136                        zoom: true,
1137                    },
1138                ],
1139            }],
1140        };
1141
1142        tmux.create_session_from_plan(&plan)
1143            .expect("session plan should succeed");
1144
1145        let recorded = runner.recorded();
1146        assert_eq!(recorded[5].args, vec!["resize-pane", "-Z", "-t", "%21"]);
1147    }
1148
1149    fn ok_capture(stdout: Vec<u8>) -> std::io::Result<CommandOutput> {
1150        Ok(CommandOutput {
1151            status: CommandStatus {
1152                success: true,
1153                code: Some(0),
1154            },
1155            stdout,
1156            stderr: Vec::new(),
1157        })
1158    }
1159}