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