Skip to main content

wisp_tmux/
lib.rs

1use std::{
2    env,
3    path::{Path, PathBuf},
4    process::Command,
5    str::FromStr,
6};
7
8use thiserror::Error;
9
10pub trait TmuxClient {
11    fn capabilities(&self) -> Result<TmuxCapabilities, TmuxError>;
12    fn current_context(&self) -> Result<TmuxContext, TmuxError>;
13    fn list_sessions(&self) -> Result<Vec<TmuxSession>, TmuxError>;
14    fn list_windows(&self) -> Result<Vec<TmuxWindow>, TmuxError>;
15    fn list_panes(&self, target: Option<&str>) -> Result<Vec<TmuxPane>, TmuxError>;
16    fn capture_pane(&self, target: &str) -> Result<String, TmuxError>;
17    fn snapshot(&self, query_windows: bool) -> Result<TmuxSnapshot, TmuxError>;
18    fn ensure_session(&self, session_name: &str, directory: &Path) -> Result<(), TmuxError>;
19    fn switch_or_attach_session(&self, session_name: &str) -> Result<(), TmuxError>;
20    fn rename_session(&self, session_name: &str, new_name: &str) -> Result<(), TmuxError>;
21    fn kill_session(&self, session_name: &str) -> Result<(), TmuxError>;
22    fn create_or_switch_session(
23        &self,
24        session_name: &str,
25        directory: &Path,
26    ) -> Result<(), TmuxError>;
27    fn open_popup(&self, command: &PopupCommand, options: &PopupOptions) -> Result<(), TmuxError>;
28    fn open_sidebar_pane(&self, spec: &SidebarPaneSpec) -> Result<String, TmuxError>;
29    fn close_sidebar_pane(&self, target: Option<&str>) -> Result<(), TmuxError>;
30    fn select_pane(&self, target: &str) -> Result<(), TmuxError>;
31    fn resize_pane_width(&self, target: &str, width: u16) -> Result<(), TmuxError>;
32    fn status_line_count(&self) -> Result<usize, TmuxError>;
33    fn set_status_line_count(&self, count: usize) -> Result<(), TmuxError>;
34    fn clear_status_line(&self, line: usize) -> Result<(), TmuxError>;
35    fn update_status_line(&self, line: usize, content: &str) -> Result<(), TmuxError>;
36    fn set_hook(&self, hook: &str, command: &str) -> Result<(), TmuxError>;
37    fn clear_hook(&self, hook: &str) -> Result<(), TmuxError>;
38    fn refresh_client_status(&self) -> Result<(), TmuxError>;
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TmuxSnapshot {
43    pub context: TmuxContext,
44    pub capabilities: TmuxCapabilities,
45    pub sessions: Vec<TmuxSession>,
46    pub windows: Vec<TmuxWindow>,
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct TmuxContext {
51    pub client_tty: Option<String>,
52    pub session_name: Option<String>,
53    pub window_index: Option<u32>,
54    pub window_name: Option<String>,
55    pub pane_id: Option<String>,
56    pub inside_tmux: bool,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct TmuxCapabilities {
61    pub version: TmuxVersion,
62    pub supports_popup: bool,
63    pub supports_multi_status_lines: bool,
64    pub supports_status_mouse_ranges: bool,
65    pub mouse_enabled: bool,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct TmuxSession {
70    pub id: String,
71    pub name: String,
72    pub attached: bool,
73    pub windows: usize,
74    pub current: bool,
75    pub last_activity: Option<u64>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct TmuxWindow {
80    pub session_name: String,
81    pub index: u32,
82    pub name: String,
83    pub active: bool,
84    pub activity: bool,
85    pub bell: bool,
86    pub silence: bool,
87    pub current_path: Option<PathBuf>,
88    pub current_command: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct TmuxPane {
93    pub session_name: String,
94    pub window_index: u32,
95    pub pane_id: String,
96    pub title: String,
97    pub active: bool,
98    pub current_command: Option<String>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct PopupCommand {
103    pub program: PathBuf,
104    pub args: Vec<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct PopupSpec {
109    pub command: PopupCommand,
110    pub options: PopupOptions,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct PopupOptions {
115    pub width: PopupDimension,
116    pub height: PopupDimension,
117    pub title: Option<String>,
118}
119
120impl Default for PopupOptions {
121    fn default() -> Self {
122        Self {
123            width: PopupDimension::Percent(80),
124            height: PopupDimension::Percent(85),
125            title: None,
126        }
127    }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum PopupDimension {
132    Percent(u8),
133    Cells(u16),
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum SidebarSide {
138    Left,
139    Right,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct SidebarPaneSpec {
144    pub target: Option<String>,
145    pub side: SidebarSide,
146    pub width: u16,
147    pub title: Option<String>,
148    pub command: PopupCommand,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum EventStrategy {
153    PollingFallback,
154    ControlMode,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum TmuxCommand {
159    EnsureSession {
160        session_name: String,
161        directory: PathBuf,
162    },
163    SwitchOrAttachSession {
164        session_name: String,
165    },
166    CreateOrSwitchSession {
167        session_name: String,
168        directory: PathBuf,
169    },
170    KillPane {
171        target: Option<String>,
172    },
173    UpdateStatusLine {
174        line: usize,
175        content: String,
176    },
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum TmuxEvent {
181    SnapshotLoaded(TmuxSnapshot),
182    SessionAdded(TmuxSession),
183    SessionRemoved(String),
184    SessionUpdated(TmuxSession),
185    FocusChanged {
186        client_id: String,
187        session_name: String,
188        window_id: String,
189        pane_id: Option<String>,
190    },
191}
192
193impl PopupDimension {
194    #[must_use]
195    pub fn format(&self) -> String {
196        match self {
197            Self::Percent(value) => format!("{value}%"),
198            Self::Cells(value) => value.to_string(),
199        }
200    }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
204pub struct TmuxVersion {
205    pub major: u8,
206    pub minor: u8,
207    pub patch: Option<u8>,
208}
209
210impl TmuxVersion {
211    #[must_use]
212    pub fn supports_popup(self) -> bool {
213        self.major > 3 || (self.major == 3 && self.minor >= 2)
214    }
215
216    #[must_use]
217    pub fn supports_multi_status_lines(self) -> bool {
218        self.major > 2 || (self.major == 2 && self.minor >= 9)
219    }
220
221    #[must_use]
222    pub fn supports_status_mouse_ranges(self) -> bool {
223        self.major > 3 || (self.major == 3 && self.minor >= 4)
224    }
225}
226
227impl FromStr for TmuxVersion {
228    type Err = TmuxError;
229
230    fn from_str(value: &str) -> Result<Self, Self::Err> {
231        let version = value
232            .strip_prefix("tmux ")
233            .ok_or_else(|| TmuxError::Parse {
234                context: "tmux version",
235                message: format!("unexpected version string `{value}`"),
236            })?;
237
238        let digits = version
239            .chars()
240            .take_while(|character| character.is_ascii_digit() || *character == '.')
241            .collect::<String>();
242        let mut parts = digits.split('.');
243        let major = parts
244            .next()
245            .ok_or_else(|| TmuxError::Parse {
246                context: "tmux version",
247                message: "missing major version".to_string(),
248            })?
249            .parse::<u8>()
250            .map_err(|_| TmuxError::Parse {
251                context: "tmux version",
252                message: format!("invalid major version in `{value}`"),
253            })?;
254        let minor = parts
255            .next()
256            .unwrap_or("0")
257            .parse::<u8>()
258            .map_err(|_| TmuxError::Parse {
259                context: "tmux version",
260                message: format!("invalid minor version in `{value}`"),
261            })?;
262        let patch = match parts.next() {
263            Some(raw_patch) => Some(raw_patch.parse::<u8>().map_err(|_| TmuxError::Parse {
264                context: "tmux version",
265                message: format!("invalid patch version in `{value}`"),
266            })?),
267            None => None,
268        };
269
270        Ok(Self {
271            major,
272            minor,
273            patch,
274        })
275    }
276}
277
278#[derive(Debug, Error)]
279pub enum TmuxError {
280    #[error("tmux is unavailable: {message}")]
281    Unavailable { message: String },
282    #[error("tmux command failed: {command:?} (status {status:?}): {stderr}")]
283    CommandFailed {
284        command: Vec<String>,
285        status: Option<i32>,
286        stderr: String,
287    },
288    #[error("failed to parse {context}: {message}")]
289    Parse {
290        context: &'static str,
291        message: String,
292    },
293    #[error("popup mode is unavailable on tmux {version}")]
294    PopupUnavailable { version: String },
295}
296
297#[derive(Debug, Clone)]
298pub struct CommandTmuxClient {
299    binary: PathBuf,
300    socket_name: Option<String>,
301    config_file: Option<PathBuf>,
302    inside_tmux: bool,
303}
304
305impl Default for CommandTmuxClient {
306    fn default() -> Self {
307        Self::new()
308    }
309}
310
311impl CommandTmuxClient {
312    #[must_use]
313    pub fn new() -> Self {
314        Self {
315            binary: PathBuf::from("tmux"),
316            socket_name: None,
317            config_file: None,
318            inside_tmux: env::var_os("TMUX").is_some(),
319        }
320    }
321
322    #[must_use]
323    pub fn with_binary(mut self, binary: impl Into<PathBuf>) -> Self {
324        self.binary = binary.into();
325        self
326    }
327
328    #[must_use]
329    pub fn with_socket_name(mut self, socket_name: impl Into<String>) -> Self {
330        self.socket_name = Some(socket_name.into());
331        self
332    }
333
334    #[must_use]
335    pub fn with_config_file(mut self, config_file: impl Into<PathBuf>) -> Self {
336        self.config_file = Some(config_file.into());
337        self
338    }
339
340    #[must_use]
341    pub fn with_inside_tmux(mut self, inside_tmux: bool) -> Self {
342        self.inside_tmux = inside_tmux;
343        self
344    }
345
346    fn run_tmux(&self, args: Vec<String>) -> Result<String, TmuxError> {
347        let command_line = self.command_line(&args);
348        let mut command = Command::new(&self.binary);
349        if let Some(socket_name) = &self.socket_name {
350            command.arg("-L").arg(socket_name);
351        }
352        if let Some(config_file) = &self.config_file {
353            command.arg("-f").arg(config_file);
354        }
355        command.args(&args);
356
357        let output = command.output().map_err(|source| {
358            if source.kind() == std::io::ErrorKind::NotFound {
359                TmuxError::Unavailable {
360                    message: source.to_string(),
361                }
362            } else {
363                TmuxError::CommandFailed {
364                    command: command_line.clone(),
365                    status: None,
366                    stderr: source.to_string(),
367                }
368            }
369        })?;
370
371        if output.status.success() {
372            Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
373        } else {
374            Err(TmuxError::CommandFailed {
375                command: command_line,
376                status: output.status.code(),
377                stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
378            })
379        }
380    }
381
382    fn command_line(&self, args: &[String]) -> Vec<String> {
383        let mut command = vec![self.binary.display().to_string()];
384        if let Some(socket_name) = &self.socket_name {
385            command.push("-L".to_string());
386            command.push(socket_name.clone());
387        }
388        if let Some(config_file) = &self.config_file {
389            command.push("-f".to_string());
390            command.push(config_file.display().to_string());
391        }
392        command.extend(args.iter().cloned());
393        command
394    }
395}
396
397impl TmuxClient for CommandTmuxClient {
398    fn capabilities(&self) -> Result<TmuxCapabilities, TmuxError> {
399        let output = self.run_tmux(vec!["-V".to_string()])?;
400        let version = output.parse::<TmuxVersion>()?;
401        let mouse_enabled = match self.run_tmux(vec![
402            "show-option".to_string(),
403            "-gv".to_string(),
404            "mouse".to_string(),
405        ]) {
406            Ok(output) => parse_tmux_bool(&output, "mouse option")?,
407            Err(TmuxError::CommandFailed { stderr, .. }) if is_no_server_error(&stderr) => false,
408            Err(error) => return Err(error),
409        };
410
411        Ok(TmuxCapabilities {
412            version,
413            supports_popup: version.supports_popup(),
414            supports_multi_status_lines: version.supports_multi_status_lines(),
415            supports_status_mouse_ranges: version.supports_status_mouse_ranges(),
416            mouse_enabled,
417        })
418    }
419
420    fn current_context(&self) -> Result<TmuxContext, TmuxError> {
421        let output = match self.run_tmux(vec![
422            "display-message".to_string(),
423            "-p".to_string(),
424            "#{client_tty}\t#{session_name}\t#{window_index}\t#{window_name}\t#{pane_id}"
425                .to_string(),
426        ]) {
427            Ok(output) => output,
428            Err(TmuxError::CommandFailed { stderr, .. })
429                if is_no_current_client_error(&stderr) || is_no_server_error(&stderr) =>
430            {
431                return Ok(TmuxContext {
432                    inside_tmux: self.inside_tmux,
433                    ..TmuxContext::default()
434                });
435            }
436            Err(error) => return Err(error),
437        };
438
439        let mut fields = output.split('\t');
440        let client_tty = empty_to_none(fields.next());
441        let session_name = empty_to_none(fields.next());
442        let window_index = fields.next().and_then(|field| field.parse::<u32>().ok());
443        let window_name = empty_to_none(fields.next());
444        let pane_id = empty_to_none(fields.next());
445
446        Ok(TmuxContext {
447            client_tty,
448            session_name,
449            window_index,
450            window_name,
451            pane_id,
452            inside_tmux: self.inside_tmux,
453        })
454    }
455
456    fn list_sessions(&self) -> Result<Vec<TmuxSession>, TmuxError> {
457        let output = match self.run_tmux(vec![
458            "list-sessions".to_string(),
459            "-F".to_string(),
460            "#{session_id}\t#{session_name}\t#{session_attached}\t#{session_windows}\t#{session_activity}".to_string(),
461        ]) {
462            Ok(output) => output,
463            Err(TmuxError::CommandFailed { stderr, .. }) if is_no_server_error(&stderr) => {
464                return Ok(Vec::new());
465            }
466            Err(error) => return Err(error),
467        };
468        let context = self.current_context()?;
469
470        parse_sessions(&output, context.session_name.as_deref())
471    }
472
473    fn list_windows(&self) -> Result<Vec<TmuxWindow>, TmuxError> {
474        let output = match self.run_tmux(vec![
475            "list-windows".to_string(),
476            "-a".to_string(),
477            "-F".to_string(),
478            "#{session_name}\t#{window_index}\t#{window_name}\t#{window_active}\t#{window_activity_flag}\t#{window_bell_flag}\t#{window_silence_flag}\t#{pane_current_path}\t#{pane_current_command}".to_string(),
479        ]) {
480            Ok(output) => output,
481            Err(TmuxError::CommandFailed { stderr, .. }) if is_no_server_error(&stderr) => {
482                return Ok(Vec::new());
483            }
484            Err(error) => return Err(error),
485        };
486
487        parse_windows(&output)
488    }
489
490    fn list_panes(&self, target: Option<&str>) -> Result<Vec<TmuxPane>, TmuxError> {
491        let mut args = vec![
492            "list-panes".to_string(),
493            "-F".to_string(),
494            "#{session_name}\t#{window_index}\t#{pane_id}\t#{pane_title}\t#{pane_active}\t#{pane_current_command}".to_string(),
495        ];
496        if let Some(target) = target {
497            args.push("-t".to_string());
498            args.push(target.to_string());
499        } else {
500            args.push("-a".to_string());
501        }
502
503        let output = match self.run_tmux(args) {
504            Ok(output) => output,
505            Err(TmuxError::CommandFailed { stderr, .. }) if is_no_server_error(&stderr) => {
506                return Ok(Vec::new());
507            }
508            Err(error) => return Err(error),
509        };
510
511        parse_panes(&output)
512    }
513
514    fn capture_pane(&self, target: &str) -> Result<String, TmuxError> {
515        self.run_tmux(vec![
516            "capture-pane".to_string(),
517            "-p".to_string(),
518            "-e".to_string(),
519            "-t".to_string(),
520            target.to_string(),
521        ])
522    }
523
524    fn snapshot(&self, query_windows: bool) -> Result<TmuxSnapshot, TmuxError> {
525        let capabilities = self.capabilities()?;
526        let context = self.current_context()?;
527        let sessions = self.list_sessions()?;
528        let windows = if query_windows {
529            self.list_windows()?
530        } else {
531            Vec::new()
532        };
533
534        Ok(TmuxSnapshot {
535            context,
536            capabilities,
537            sessions,
538            windows,
539        })
540    }
541
542    fn ensure_session(&self, session_name: &str, directory: &Path) -> Result<(), TmuxError> {
543        match self.run_tmux(vec![
544            "new-session".to_string(),
545            "-Ad".to_string(),
546            "-s".to_string(),
547            session_name.to_string(),
548            "-c".to_string(),
549            directory.display().to_string(),
550        ]) {
551            Ok(_) => Ok(()),
552            Err(TmuxError::CommandFailed { stderr, .. })
553                if stderr.contains("duplicate session") =>
554            {
555                Ok(())
556            }
557            Err(error) => Err(error),
558        }
559    }
560
561    fn switch_or_attach_session(&self, session_name: &str) -> Result<(), TmuxError> {
562        self.run_tmux(focus_session_command(session_name, self.inside_tmux))
563            .map(|_| ())
564    }
565
566    fn rename_session(&self, session_name: &str, new_name: &str) -> Result<(), TmuxError> {
567        self.run_tmux(vec![
568            "rename-session".to_string(),
569            "-t".to_string(),
570            session_name.to_string(),
571            new_name.to_string(),
572        ])
573        .map(|_| ())
574    }
575
576    fn create_or_switch_session(
577        &self,
578        session_name: &str,
579        directory: &Path,
580    ) -> Result<(), TmuxError> {
581        self.ensure_session(session_name, directory)?;
582        self.switch_or_attach_session(session_name)
583    }
584
585    fn kill_session(&self, session_name: &str) -> Result<(), TmuxError> {
586        self.run_tmux(vec![
587            "kill-session".to_string(),
588            "-t".to_string(),
589            session_name.to_string(),
590        ])
591        .map(|_| ())
592    }
593
594    fn open_popup(&self, command: &PopupCommand, options: &PopupOptions) -> Result<(), TmuxError> {
595        let capabilities = self.capabilities()?;
596        if !capabilities.supports_popup {
597            return Err(TmuxError::PopupUnavailable {
598                version: format!(
599                    "{}.{}",
600                    capabilities.version.major, capabilities.version.minor
601                ),
602            });
603        }
604
605        let mut args = vec![
606            "display-popup".to_string(),
607            "-E".to_string(),
608            "-w".to_string(),
609            options.width.format(),
610            "-h".to_string(),
611            options.height.format(),
612        ];
613        if let Some(title) = &options.title {
614            args.push("-T".to_string());
615            args.push(title.clone());
616        }
617        args.push(format_popup_command(command));
618
619        self.run_tmux(args).map(|_| ())
620    }
621
622    fn open_sidebar_pane(&self, spec: &SidebarPaneSpec) -> Result<String, TmuxError> {
623        let pane_id = self.run_tmux(sidebar_pane_command(spec))?;
624        if let Some(title) = &spec.title {
625            self.run_tmux(select_pane_title_command(&pane_id, title))
626                .map(|_| ())?;
627        }
628        Ok(pane_id)
629    }
630
631    fn close_sidebar_pane(&self, target: Option<&str>) -> Result<(), TmuxError> {
632        let mut args = vec!["kill-pane".to_string()];
633        if let Some(target) = target {
634            args.push("-t".to_string());
635            args.push(target.to_string());
636        }
637        self.run_tmux(args).map(|_| ())
638    }
639
640    fn select_pane(&self, target: &str) -> Result<(), TmuxError> {
641        self.run_tmux(select_pane_command(target)).map(|_| ())
642    }
643
644    fn resize_pane_width(&self, target: &str, width: u16) -> Result<(), TmuxError> {
645        self.run_tmux(resize_pane_width_command(target, width))
646            .map(|_| ())
647    }
648
649    fn status_line_count(&self) -> Result<usize, TmuxError> {
650        let output = match self.run_tmux(vec![
651            "show-option".to_string(),
652            "-gv".to_string(),
653            "status".to_string(),
654        ]) {
655            Ok(output) => output,
656            Err(TmuxError::CommandFailed { stderr, .. }) if is_no_server_error(&stderr) => {
657                return Ok(1);
658            }
659            Err(error) => return Err(error),
660        };
661        parse_status_line_count(&output)
662    }
663
664    fn set_status_line_count(&self, count: usize) -> Result<(), TmuxError> {
665        self.run_tmux(status_line_count_command(count)).map(|_| ())
666    }
667
668    fn clear_status_line(&self, line: usize) -> Result<(), TmuxError> {
669        self.run_tmux(clear_status_line_command(line)).map(|_| ())
670    }
671
672    fn update_status_line(&self, line: usize, content: &str) -> Result<(), TmuxError> {
673        self.run_tmux(status_line_command(line, content))
674            .map(|_| ())
675    }
676
677    fn set_hook(&self, hook: &str, command: &str) -> Result<(), TmuxError> {
678        self.run_tmux(set_hook_command(hook, command)).map(|_| ())
679    }
680
681    fn clear_hook(&self, hook: &str) -> Result<(), TmuxError> {
682        self.run_tmux(clear_hook_command(hook)).map(|_| ())
683    }
684
685    fn refresh_client_status(&self) -> Result<(), TmuxError> {
686        self.run_tmux(refresh_client_status_command()).map(|_| ())
687    }
688}
689
690#[must_use]
691pub fn focus_session_command(session_name: &str, inside_tmux: bool) -> Vec<String> {
692    if inside_tmux {
693        vec![
694            "switch-client".to_string(),
695            "-t".to_string(),
696            session_name.to_string(),
697        ]
698    } else {
699        vec![
700            "attach-session".to_string(),
701            "-t".to_string(),
702            session_name.to_string(),
703        ]
704    }
705}
706
707#[must_use]
708pub fn format_popup_command(command: &PopupCommand) -> String {
709    let mut parts = Vec::with_capacity(1 + command.args.len());
710    parts.push(shell_escape(&command.program.display().to_string()));
711    parts.extend(command.args.iter().map(|arg| shell_escape(arg)));
712    parts.join(" ")
713}
714
715#[must_use]
716pub fn sidebar_pane_command(spec: &SidebarPaneSpec) -> Vec<String> {
717    let mut args = vec![
718        "split-window".to_string(),
719        "-d".to_string(),
720        "-h".to_string(),
721        "-P".to_string(),
722        "-F".to_string(),
723        "#{pane_id}".to_string(),
724    ];
725    if matches!(spec.side, SidebarSide::Left) {
726        args.push("-b".to_string());
727    }
728    if let Some(target) = &spec.target {
729        args.push("-t".to_string());
730        args.push(target.clone());
731    }
732    args.push("-l".to_string());
733    args.push(spec.width.to_string());
734    args.push(format_popup_command(&spec.command));
735    args
736}
737
738#[must_use]
739pub fn select_pane_command(target: &str) -> Vec<String> {
740    vec![
741        "select-pane".to_string(),
742        "-t".to_string(),
743        target.to_string(),
744    ]
745}
746
747#[must_use]
748pub fn select_pane_title_command(target: &str, title: &str) -> Vec<String> {
749    vec![
750        "select-pane".to_string(),
751        "-T".to_string(),
752        title.to_string(),
753        "-t".to_string(),
754        target.to_string(),
755    ]
756}
757
758#[must_use]
759pub fn resize_pane_width_command(target: &str, width: u16) -> Vec<String> {
760    vec![
761        "resize-pane".to_string(),
762        "-x".to_string(),
763        width.to_string(),
764        "-t".to_string(),
765        target.to_string(),
766    ]
767}
768
769#[must_use]
770pub fn status_line_command(line: usize, content: &str) -> Vec<String> {
771    let slot = line.saturating_sub(1);
772    vec![
773        "set-option".to_string(),
774        "-gq".to_string(),
775        format!("status-format[{slot}]"),
776        content.to_string(),
777    ]
778}
779
780#[must_use]
781pub fn clear_status_line_command(line: usize) -> Vec<String> {
782    let slot = line.saturating_sub(1);
783    vec![
784        "set-option".to_string(),
785        "-guq".to_string(),
786        format!("status-format[{slot}]"),
787    ]
788}
789
790#[must_use]
791pub fn status_line_count_command(count: usize) -> Vec<String> {
792    vec![
793        "set-option".to_string(),
794        "-gq".to_string(),
795        "status".to_string(),
796        count.to_string(),
797    ]
798}
799
800#[must_use]
801pub fn set_hook_command(hook: &str, command: &str) -> Vec<String> {
802    vec![
803        "set-hook".to_string(),
804        "-g".to_string(),
805        hook.to_string(),
806        command.to_string(),
807    ]
808}
809
810#[must_use]
811pub fn clear_hook_command(hook: &str) -> Vec<String> {
812    vec!["set-hook".to_string(), "-gu".to_string(), hook.to_string()]
813}
814
815#[must_use]
816pub fn refresh_client_status_command() -> Vec<String> {
817    vec!["refresh-client".to_string(), "-S".to_string()]
818}
819
820pub trait TmuxBackend {
821    fn event_strategy(&self) -> EventStrategy;
822    fn snapshot(&self) -> Result<TmuxSnapshot, TmuxError>;
823    fn poll_events(&mut self) -> Result<Vec<TmuxEvent>, TmuxError>;
824    fn send(&self, command: TmuxCommand) -> Result<(), TmuxError>;
825    fn open_popup(&self, spec: &PopupSpec) -> Result<(), TmuxError>;
826    fn open_sidebar_pane(&self, spec: &SidebarPaneSpec) -> Result<String, TmuxError>;
827    fn close_sidebar_pane(&self, target: Option<&str>) -> Result<(), TmuxError>;
828    fn resize_pane_width(&self, target: &str, width: u16) -> Result<(), TmuxError>;
829    fn update_status_line(&self, line: usize, content: &str) -> Result<(), TmuxError>;
830}
831
832#[derive(Debug, Clone)]
833pub struct PollingTmuxBackend {
834    client: CommandTmuxClient,
835    query_windows: bool,
836    previous_snapshot: Option<TmuxSnapshot>,
837}
838
839impl PollingTmuxBackend {
840    #[must_use]
841    pub fn new(client: CommandTmuxClient) -> Self {
842        Self {
843            client,
844            query_windows: true,
845            previous_snapshot: None,
846        }
847    }
848
849    #[must_use]
850    pub fn with_windows(mut self, query_windows: bool) -> Self {
851        self.query_windows = query_windows;
852        self
853    }
854}
855
856impl TmuxBackend for PollingTmuxBackend {
857    fn event_strategy(&self) -> EventStrategy {
858        EventStrategy::PollingFallback
859    }
860
861    fn snapshot(&self) -> Result<TmuxSnapshot, TmuxError> {
862        self.client.snapshot(self.query_windows)
863    }
864
865    fn poll_events(&mut self) -> Result<Vec<TmuxEvent>, TmuxError> {
866        let snapshot = self.snapshot()?;
867        let events = match &self.previous_snapshot {
868            Some(previous) => diff_snapshots(previous, &snapshot),
869            None => vec![TmuxEvent::SnapshotLoaded(snapshot.clone())],
870        };
871        self.previous_snapshot = Some(snapshot);
872        Ok(events)
873    }
874
875    fn send(&self, command: TmuxCommand) -> Result<(), TmuxError> {
876        match command {
877            TmuxCommand::EnsureSession {
878                session_name,
879                directory,
880            } => self.client.ensure_session(&session_name, &directory),
881            TmuxCommand::SwitchOrAttachSession { session_name } => {
882                self.client.switch_or_attach_session(&session_name)
883            }
884            TmuxCommand::CreateOrSwitchSession {
885                session_name,
886                directory,
887            } => self
888                .client
889                .create_or_switch_session(&session_name, &directory),
890            TmuxCommand::KillPane { target } => self.client.close_sidebar_pane(target.as_deref()),
891            TmuxCommand::UpdateStatusLine { line, content } => {
892                self.client.update_status_line(line, &content)
893            }
894        }
895    }
896
897    fn open_popup(&self, spec: &PopupSpec) -> Result<(), TmuxError> {
898        self.client.open_popup(&spec.command, &spec.options)
899    }
900
901    fn open_sidebar_pane(&self, spec: &SidebarPaneSpec) -> Result<String, TmuxError> {
902        self.client.open_sidebar_pane(spec)
903    }
904
905    fn close_sidebar_pane(&self, target: Option<&str>) -> Result<(), TmuxError> {
906        self.client.close_sidebar_pane(target)
907    }
908
909    fn resize_pane_width(&self, target: &str, width: u16) -> Result<(), TmuxError> {
910        self.client.resize_pane_width(target, width)
911    }
912
913    fn update_status_line(&self, line: usize, content: &str) -> Result<(), TmuxError> {
914        self.client.update_status_line(line, content)
915    }
916}
917
918#[must_use]
919pub fn diff_snapshots(previous: &TmuxSnapshot, next: &TmuxSnapshot) -> Vec<TmuxEvent> {
920    let mut events = Vec::new();
921
922    for next_session in &next.sessions {
923        match previous
924            .sessions
925            .iter()
926            .find(|session| session.name == next_session.name)
927        {
928            None => events.push(TmuxEvent::SessionAdded(next_session.clone())),
929            Some(previous_session) if previous_session != next_session => {
930                events.push(TmuxEvent::SessionUpdated(next_session.clone()));
931            }
932            Some(_) => {}
933        }
934    }
935
936    for previous_session in &previous.sessions {
937        if next
938            .sessions
939            .iter()
940            .all(|session| session.name != previous_session.name)
941        {
942            events.push(TmuxEvent::SessionRemoved(previous_session.name.clone()));
943        }
944    }
945
946    if (previous.context.session_name != next.context.session_name
947        || previous.context.window_index != next.context.window_index
948        || previous.context.pane_id != next.context.pane_id)
949        && let (Some(session_name), Some(window_index)) =
950            (next.context.session_name.clone(), next.context.window_index)
951    {
952        events.push(TmuxEvent::FocusChanged {
953            client_id: next
954                .context
955                .client_tty
956                .clone()
957                .unwrap_or_else(|| "default".to_string()),
958            window_id: format!("{session_name}:{window_index}"),
959            session_name,
960            pane_id: next.context.pane_id.clone(),
961        });
962    }
963
964    events
965}
966
967fn shell_escape(value: &str) -> String {
968    if value.is_empty() {
969        return "''".to_string();
970    }
971
972    let escaped = value.replace('\'', "'\"'\"'");
973    format!("'{escaped}'")
974}
975
976fn parse_sessions(
977    output: &str,
978    current_session: Option<&str>,
979) -> Result<Vec<TmuxSession>, TmuxError> {
980    output
981        .lines()
982        .filter(|line| !line.trim().is_empty())
983        .map(|line| {
984            let mut fields = line.split('\t');
985            let id = required_field(fields.next(), "session id", line)?;
986            let name = required_field(fields.next(), "session name", line)?;
987            let attached =
988                parse_numeric_bool(required_field(fields.next(), "session attached", line)?)?;
989            let windows = required_field(fields.next(), "session windows", line)?
990                .parse::<usize>()
991                .map_err(|_| TmuxError::Parse {
992                    context: "tmux sessions",
993                    message: format!("invalid window count in `{line}`"),
994                })?;
995            let last_activity = empty_to_none(fields.next())
996                .map(|raw| {
997                    raw.parse::<u64>().map_err(|_| TmuxError::Parse {
998                        context: "tmux sessions",
999                        message: format!("invalid session activity in `{line}`"),
1000                    })
1001                })
1002                .transpose()?;
1003
1004            Ok(TmuxSession {
1005                id: id.to_string(),
1006                current: current_session.is_some_and(|current| current == name),
1007                name: name.to_string(),
1008                attached,
1009                windows,
1010                last_activity,
1011            })
1012        })
1013        .collect()
1014}
1015
1016fn parse_windows(output: &str) -> Result<Vec<TmuxWindow>, TmuxError> {
1017    output
1018        .lines()
1019        .filter(|line| !line.trim().is_empty())
1020        .map(|line| {
1021            let mut fields = line.split('\t');
1022            let session_name = required_field(fields.next(), "window session", line)?;
1023            let index = required_field(fields.next(), "window index", line)?
1024                .parse::<u32>()
1025                .map_err(|_| TmuxError::Parse {
1026                    context: "tmux windows",
1027                    message: format!("invalid window index in `{line}`"),
1028                })?;
1029            let name = required_field(fields.next(), "window name", line)?;
1030            let active = parse_numeric_bool(required_field(fields.next(), "window active", line)?)?;
1031            let activity =
1032                parse_numeric_bool(required_field(fields.next(), "window activity", line)?)?;
1033            let bell = parse_numeric_bool(required_field(fields.next(), "window bell", line)?)?;
1034            let silence =
1035                parse_numeric_bool(required_field(fields.next(), "window silence", line)?)?;
1036            let current_path = empty_to_none(fields.next()).map(PathBuf::from);
1037            let current_command = empty_to_none(fields.next());
1038
1039            Ok(TmuxWindow {
1040                session_name: session_name.to_string(),
1041                index,
1042                name: name.to_string(),
1043                active,
1044                activity,
1045                bell,
1046                silence,
1047                current_path,
1048                current_command,
1049            })
1050        })
1051        .collect()
1052}
1053
1054fn parse_panes(output: &str) -> Result<Vec<TmuxPane>, TmuxError> {
1055    output
1056        .lines()
1057        .filter(|line| !line.trim().is_empty())
1058        .map(|line| {
1059            let mut fields = line.split('\t');
1060            let session_name = required_field(fields.next(), "pane session", line)?;
1061            let window_index = required_field(fields.next(), "pane window index", line)?
1062                .parse::<u32>()
1063                .map_err(|_| TmuxError::Parse {
1064                    context: "tmux panes",
1065                    message: format!("invalid window index in `{line}`"),
1066                })?;
1067            let pane_id = required_field(fields.next(), "pane id", line)?;
1068            let title = required_field(fields.next(), "pane title", line)?;
1069            let active = parse_numeric_bool(required_field(fields.next(), "pane active", line)?)?;
1070            let current_command = empty_to_none(fields.next());
1071
1072            Ok(TmuxPane {
1073                session_name: session_name.to_string(),
1074                window_index,
1075                pane_id: pane_id.to_string(),
1076                title: title.to_string(),
1077                active,
1078                current_command,
1079            })
1080        })
1081        .collect()
1082}
1083
1084fn parse_numeric_bool(value: &str) -> Result<bool, TmuxError> {
1085    value
1086        .parse::<u8>()
1087        .map(|parsed| parsed > 0)
1088        .map_err(|_| TmuxError::Parse {
1089            context: "tmux output",
1090            message: format!("expected numeric boolean, got `{value}`"),
1091        })
1092}
1093
1094fn parse_tmux_bool(value: &str, context: &'static str) -> Result<bool, TmuxError> {
1095    match value.trim() {
1096        "on" => Ok(true),
1097        "off" => Ok(false),
1098        other => Err(TmuxError::Parse {
1099            context,
1100            message: format!("expected `on` or `off`, got `{other}`"),
1101        }),
1102    }
1103}
1104
1105fn parse_status_line_count(value: &str) -> Result<usize, TmuxError> {
1106    match value.trim() {
1107        "off" => Ok(0),
1108        "on" => Ok(1),
1109        other => other.parse::<usize>().map_err(|_| TmuxError::Parse {
1110            context: "status option",
1111            message: format!("expected `on`, `off`, or a number, got `{other}`"),
1112        }),
1113    }
1114}
1115
1116fn required_field<'line>(
1117    value: Option<&'line str>,
1118    field: &'static str,
1119    line: &'line str,
1120) -> Result<&'line str, TmuxError> {
1121    value.ok_or_else(|| TmuxError::Parse {
1122        context: "tmux output",
1123        message: format!("missing {field} in `{line}`"),
1124    })
1125}
1126
1127fn empty_to_none(value: Option<&str>) -> Option<String> {
1128    value
1129        .map(str::trim)
1130        .filter(|field| !field.is_empty())
1131        .map(ToOwned::to_owned)
1132}
1133
1134fn is_no_current_client_error(stderr: &str) -> bool {
1135    stderr.contains("no current client") || stderr.contains("no current target")
1136}
1137
1138fn is_no_server_error(stderr: &str) -> bool {
1139    stderr.contains("no server running")
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144    use std::path::PathBuf;
1145
1146    use crate::{
1147        EventStrategy, PollingTmuxBackend, PopupCommand, PopupDimension, SidebarPaneSpec,
1148        SidebarSide, TmuxBackend, TmuxContext, TmuxEvent, TmuxPane, TmuxSession, TmuxSnapshot,
1149        TmuxVersion, TmuxWindow, clear_hook_command, clear_status_line_command, diff_snapshots,
1150        focus_session_command, format_popup_command, parse_panes, parse_sessions,
1151        parse_status_line_count, parse_windows, refresh_client_status_command,
1152        resize_pane_width_command, select_pane_command, select_pane_title_command,
1153        set_hook_command, sidebar_pane_command, status_line_command, status_line_count_command,
1154    };
1155
1156    #[test]
1157    fn parses_tmux_versions_with_suffixes() {
1158        let version = "tmux 3.6a"
1159            .parse::<TmuxVersion>()
1160            .expect("version should parse");
1161
1162        assert_eq!(version.major, 3);
1163        assert_eq!(version.minor, 6);
1164        assert!(version.supports_popup());
1165        assert!(version.supports_status_mouse_ranges());
1166    }
1167
1168    #[test]
1169    fn selects_attach_or_switch_command_by_context() {
1170        assert_eq!(
1171            focus_session_command("work", false),
1172            vec!["attach-session", "-t", "work"]
1173        );
1174        assert_eq!(
1175            focus_session_command("work", true),
1176            vec!["switch-client", "-t", "work"]
1177        );
1178    }
1179
1180    #[test]
1181    fn shell_quotes_popup_commands() {
1182        let command = PopupCommand {
1183            program: PathBuf::from("/tmp/wisp"),
1184            args: vec!["popup".to_string(), "quote's test".to_string()],
1185        };
1186
1187        assert_eq!(
1188            format_popup_command(&command),
1189            "'/tmp/wisp' 'popup' 'quote'\"'\"'s test'"
1190        );
1191    }
1192
1193    #[test]
1194    fn formats_popup_dimensions() {
1195        assert_eq!(PopupDimension::Percent(80).format(), "80%");
1196        assert_eq!(PopupDimension::Cells(40).format(), "40");
1197    }
1198
1199    #[test]
1200    fn builds_sidebar_pane_commands() {
1201        let command = PopupCommand {
1202            program: PathBuf::from("/tmp/wisp"),
1203            args: vec!["sidebar".to_string()],
1204        };
1205
1206        let args = sidebar_pane_command(&SidebarPaneSpec {
1207            target: Some("alpha:1".to_string()),
1208            side: SidebarSide::Left,
1209            width: 36,
1210            title: Some("Wisp Sidebar".to_string()),
1211            command,
1212        });
1213
1214        assert_eq!(
1215            args,
1216            vec![
1217                "split-window",
1218                "-d",
1219                "-h",
1220                "-P",
1221                "-F",
1222                "#{pane_id}",
1223                "-b",
1224                "-t",
1225                "alpha:1",
1226                "-l",
1227                "36",
1228                "'/tmp/wisp' 'sidebar'",
1229            ]
1230        );
1231    }
1232
1233    #[test]
1234    fn parses_tmux_panes() {
1235        let panes =
1236            parse_panes("alpha\t1\t%7\tWisp Sidebar\t1\twisp\n").expect("panes should parse");
1237
1238        assert_eq!(
1239            panes,
1240            vec![TmuxPane {
1241                session_name: "alpha".to_string(),
1242                window_index: 1,
1243                pane_id: "%7".to_string(),
1244                title: "Wisp Sidebar".to_string(),
1245                active: true,
1246                current_command: Some("wisp".to_string()),
1247            }]
1248        );
1249    }
1250
1251    #[test]
1252    fn parses_tmux_windows_with_alert_flags() {
1253        let windows = parse_windows("alpha\t1\tshell\t1\t1\t0\t1\t/tmp\tbash\n")
1254            .expect("windows should parse");
1255
1256        assert_eq!(
1257            windows,
1258            vec![TmuxWindow {
1259                session_name: "alpha".to_string(),
1260                index: 1,
1261                name: "shell".to_string(),
1262                active: true,
1263                activity: true,
1264                bell: false,
1265                silence: true,
1266                current_path: Some(PathBuf::from("/tmp")),
1267                current_command: Some("bash".to_string()),
1268            }]
1269        );
1270    }
1271
1272    #[test]
1273    fn parses_tmux_sessions_with_tmux_ids() {
1274        let sessions =
1275            parse_sessions("$1\talpha\t1\t2\t42\n", Some("alpha")).expect("sessions should parse");
1276
1277        assert_eq!(
1278            sessions,
1279            vec![TmuxSession {
1280                id: "$1".to_string(),
1281                name: "alpha".to_string(),
1282                attached: true,
1283                windows: 2,
1284                current: true,
1285                last_activity: Some(42),
1286            }]
1287        );
1288    }
1289
1290    #[test]
1291    fn builds_select_pane_commands() {
1292        assert_eq!(select_pane_command("%3"), vec!["select-pane", "-t", "%3"]);
1293        assert_eq!(
1294            select_pane_title_command("%3", "Wisp Sidebar"),
1295            vec!["select-pane", "-T", "Wisp Sidebar", "-t", "%3"]
1296        );
1297        assert_eq!(
1298            resize_pane_width_command("%3", 36),
1299            vec!["resize-pane", "-x", "36", "-t", "%3"]
1300        );
1301    }
1302
1303    #[test]
1304    fn builds_status_line_option_updates() {
1305        assert_eq!(
1306            status_line_command(2, "Wisp  main"),
1307            vec!["set-option", "-gq", "status-format[1]", "Wisp  main"]
1308        );
1309        assert_eq!(
1310            clear_status_line_command(2),
1311            vec!["set-option", "-guq", "status-format[1]"]
1312        );
1313        assert_eq!(
1314            status_line_count_command(2),
1315            vec!["set-option", "-gq", "status", "2"]
1316        );
1317        assert_eq!(
1318            set_hook_command("client-session-changed[99]", "refresh-client -S"),
1319            vec![
1320                "set-hook",
1321                "-g",
1322                "client-session-changed[99]",
1323                "refresh-client -S"
1324            ]
1325        );
1326        assert_eq!(
1327            clear_hook_command("client-session-changed[99]"),
1328            vec!["set-hook", "-gu", "client-session-changed[99]"]
1329        );
1330        assert_eq!(
1331            refresh_client_status_command(),
1332            vec!["refresh-client", "-S"]
1333        );
1334    }
1335
1336    #[test]
1337    fn parses_status_line_counts() {
1338        assert_eq!(parse_status_line_count("off").expect("off count"), 0);
1339        assert_eq!(parse_status_line_count("on").expect("on count"), 1);
1340        assert_eq!(parse_status_line_count("3").expect("numeric count"), 3);
1341    }
1342
1343    #[test]
1344    fn diffs_snapshots_into_events() {
1345        let previous = TmuxSnapshot {
1346            context: TmuxContext::default(),
1347            capabilities: crate::TmuxCapabilities {
1348                version: TmuxVersion {
1349                    major: 3,
1350                    minor: 6,
1351                    patch: None,
1352                },
1353                supports_popup: true,
1354                supports_multi_status_lines: true,
1355                supports_status_mouse_ranges: true,
1356                mouse_enabled: true,
1357            },
1358            sessions: vec![TmuxSession {
1359                id: "$1".to_string(),
1360                name: "alpha".to_string(),
1361                attached: false,
1362                windows: 1,
1363                current: false,
1364                last_activity: Some(1),
1365            }],
1366            windows: Vec::new(),
1367        };
1368        let next = TmuxSnapshot {
1369            context: TmuxContext {
1370                client_tty: Some("tty1".to_string()),
1371                session_name: Some("beta".to_string()),
1372                window_index: Some(1),
1373                window_name: Some("shell".to_string()),
1374                pane_id: Some("%1".to_string()),
1375                inside_tmux: true,
1376            },
1377            capabilities: previous.capabilities.clone(),
1378            sessions: vec![
1379                TmuxSession {
1380                    id: "$1".to_string(),
1381                    name: "alpha".to_string(),
1382                    attached: true,
1383                    windows: 2,
1384                    current: false,
1385                    last_activity: Some(2),
1386                },
1387                TmuxSession {
1388                    id: "$2".to_string(),
1389                    name: "beta".to_string(),
1390                    attached: false,
1391                    windows: 1,
1392                    current: true,
1393                    last_activity: Some(3),
1394                },
1395            ],
1396            windows: vec![TmuxWindow {
1397                session_name: "beta".to_string(),
1398                index: 1,
1399                name: "shell".to_string(),
1400                active: true,
1401                activity: false,
1402                bell: false,
1403                silence: false,
1404                current_path: None,
1405                current_command: None,
1406            }],
1407        };
1408
1409        let events = diff_snapshots(&previous, &next);
1410
1411        assert!(events.iter().any(
1412            |event| matches!(event, TmuxEvent::SessionAdded(session) if session.name == "beta")
1413        ));
1414        assert!(events.iter().any(
1415            |event| matches!(event, TmuxEvent::SessionUpdated(session) if session.name == "alpha")
1416        ));
1417        assert!(events.iter().any(|event| matches!(event, TmuxEvent::FocusChanged { session_name, .. } if session_name == "beta")));
1418    }
1419
1420    #[test]
1421    fn polling_backend_reports_polling_strategy() {
1422        let backend = PollingTmuxBackend::new(crate::CommandTmuxClient::new());
1423
1424        assert_eq!(backend.event_strategy(), EventStrategy::PollingFallback);
1425    }
1426}