1use std::collections::HashSet;
13
14use crate::handles::session::unexpected_response;
15use crate::{
16 Pane, PaneId, PaneInfo, PaneProcessState, PaneSet, Result, Rmux, RmuxError, Session, SessionId,
17 SessionName, WindowId,
18};
19use rmux_proto::{ListPanesRequest, Request, Response};
20
21const PANE_DISCOVERY_FORMAT: &str = "#{window_index}:#{pane_index}:#{pane_id}";
22
23#[derive(Debug, Clone)]
25pub struct DiscoveredPane {
26 pub session_name: SessionName,
28 pub session_id: SessionId,
30 pub window_id: WindowId,
32 pub window_index: u32,
34 pub pane_id: PaneId,
36 pub pane_index: u32,
38 pub title: Option<String>,
40 pub command: Option<Vec<String>>,
42 pub working_directory: Option<String>,
44 pub tags: Vec<String>,
46 pub process: PaneProcessState,
48 pub pane: Pane,
50}
51
52#[derive(Debug)]
54pub struct DiscoveredSession {
55 pub name: SessionName,
57 pub session: Session,
59}
60
61#[derive(Debug)]
63#[must_use = "session discovery queries do nothing unless all() or one() is awaited"]
64pub struct SessionFinder<'a> {
65 rmux: &'a Rmux,
66 name: Option<String>,
67}
68
69impl<'a> SessionFinder<'a> {
70 pub(crate) const fn new(rmux: &'a Rmux) -> Self {
71 Self { rmux, name: None }
72 }
73
74 pub fn name(mut self, name: impl AsRef<str>) -> Self {
76 self.name = Some(name.as_ref().to_owned());
77 self
78 }
79
80 pub async fn all(self) -> Result<Vec<DiscoveredSession>> {
82 let names = match &self.name {
83 Some(name) => {
84 let name = SessionName::new(name).map_err(RmuxError::protocol)?;
85 if self.rmux.has_session(name.clone()).await? {
86 vec![name]
87 } else {
88 Vec::new()
89 }
90 }
91 None => self.rmux.list_sessions().await?,
92 };
93 let mut sessions = Vec::new();
94 for name in names {
95 let session = self.rmux.session(name.clone()).await?;
96 sessions.push(DiscoveredSession { name, session });
97 }
98 Ok(sessions)
99 }
100
101 pub async fn one(self) -> Result<Session> {
103 let matches = self.all().await?;
104 match matches.len() {
105 1 => Ok(matches
106 .into_iter()
107 .next()
108 .expect("single match length guarantees one entry")
109 .session),
110 count => Err(RmuxError::protocol(rmux_proto::RmuxError::Server(format!(
111 "strict session discovery violation: expected 1 match, found {count}"
112 )))),
113 }
114 }
115}
116
117#[derive(Debug)]
119#[must_use = "pane discovery queries do nothing unless all(), one(), or collect_paneset() is awaited"]
120pub struct PaneFinder<'a> {
121 rmux: &'a Rmux,
122 filters: PaneFilters,
123}
124
125#[derive(Debug, Default, Clone)]
126struct PaneFilters {
127 session: Option<String>,
128 title: Option<String>,
129 title_prefix: Option<String>,
130 command_contains: Option<String>,
131 cwd_contains: Option<String>,
132 running: Option<bool>,
133 window_index: Option<u32>,
134}
135
136impl<'a> PaneFinder<'a> {
137 pub(crate) fn new(rmux: &'a Rmux) -> Self {
138 Self {
139 rmux,
140 filters: PaneFilters::default(),
141 }
142 }
143
144 pub fn session(mut self, session_name: impl AsRef<str>) -> Self {
146 self.filters.session = Some(session_name.as_ref().to_owned());
147 self
148 }
149
150 pub fn title(mut self, title: impl Into<String>) -> Self {
152 self.filters.title = Some(title.into());
153 self
154 }
155
156 pub fn title_prefix(mut self, prefix: impl Into<String>) -> Self {
158 self.filters.title_prefix = Some(prefix.into());
159 self
160 }
161
162 pub fn command_contains(mut self, needle: impl Into<String>) -> Self {
164 self.filters.command_contains = Some(needle.into());
165 self
166 }
167
168 pub fn cwd_contains(mut self, needle: impl Into<String>) -> Self {
170 self.filters.cwd_contains = Some(needle.into());
171 self
172 }
173
174 pub const fn window_index(mut self, index: u32) -> Self {
176 self.filters.window_index = Some(index);
177 self
178 }
179
180 pub const fn running(mut self) -> Self {
182 self.filters.running = Some(true);
183 self
184 }
185
186 pub const fn exited(mut self) -> Self {
188 self.filters.running = Some(false);
189 self
190 }
191
192 pub async fn all(self) -> Result<Vec<DiscoveredPane>> {
194 discover_panes(self.rmux, &self.filters).await
195 }
196
197 pub async fn one(self) -> Result<Pane> {
202 let filters = self.filters.clone();
203 let matches = discover_panes(self.rmux, &filters).await?;
204 match matches.len() {
205 1 => Ok(matches
206 .into_iter()
207 .next()
208 .expect("single match length guarantees one entry")
209 .pane),
210 count => Err(strict_discovery_error(count, &filters)),
211 }
212 }
213
214 pub async fn collect_paneset(self) -> Result<PaneSet> {
216 let panes = self
217 .all()
218 .await?
219 .into_iter()
220 .map(|discovered| discovered.pane);
221 Ok(PaneSet::new(panes))
222 }
223}
224
225async fn discover_panes(rmux: &Rmux, filters: &PaneFilters) -> Result<Vec<DiscoveredPane>> {
226 let session_names = match &filters.session {
227 Some(session_name) => vec![SessionName::new(session_name).map_err(RmuxError::protocol)?],
228 None => rmux.list_sessions().await?,
229 };
230
231 let mut seen = HashSet::new();
232 let mut discovered = Vec::new();
233 for session_name in session_names {
234 if !rmux.has_session(session_name.clone()).await? {
235 continue;
236 }
237 let session = rmux.session(session_name.clone()).await?;
238 for listed in list_session_panes(&session).await? {
239 if !seen.insert((session_name.clone(), listed.pane_id)) {
240 continue;
241 }
242 let pane = session.pane_by_id(listed.pane_id).await?;
243 let snapshot = pane.info().await?;
244 let Some(info) = snapshot.pane(listed.pane_id).cloned() else {
245 continue;
246 };
247 let title = pane.title().await?;
248 let Some(entry) =
249 discovered_from_info(session_name.clone(), listed, pane, info, title, &snapshot)
250 else {
251 continue;
252 };
253 if filters.matches(&entry) {
254 discovered.push(entry);
255 }
256 }
257 }
258
259 Ok(discovered)
260}
261
262fn discovered_from_info(
263 session_name: SessionName,
264 listed: ListedPane,
265 pane: Pane,
266 info: PaneInfo,
267 title: Option<String>,
268 snapshot: &crate::InfoSnapshot,
269) -> Option<DiscoveredPane> {
270 let window = snapshot.window(info.window_id)?;
271 let tags = merge_tags(
272 &info.tags,
273 &window.tags,
274 snapshot
275 .session(info.session_id)
276 .map(|session| &session.tags),
277 );
278 Some(DiscoveredPane {
279 session_name,
280 session_id: info.session_id,
281 window_id: info.window_id,
282 window_index: listed.window_index,
283 pane_id: info.id,
284 pane_index: listed.pane_index,
285 title,
286 command: info.command,
287 working_directory: info.working_directory,
288 tags,
289 process: info.process,
290 pane,
291 })
292}
293
294fn merge_tags(pane: &[String], window: &[String], session: Option<&Vec<String>>) -> Vec<String> {
295 let mut tags = Vec::new();
296 for tag in pane
297 .iter()
298 .chain(window.iter())
299 .chain(session.into_iter().flatten())
300 {
301 if !tags.iter().any(|seen| seen == tag) {
302 tags.push(tag.clone());
303 }
304 }
305 tags
306}
307
308impl PaneFilters {
309 fn matches(&self, pane: &DiscoveredPane) -> bool {
310 if let Some(title) = &self.title {
311 if pane.title.as_deref() != Some(title.as_str()) {
312 return false;
313 }
314 }
315 if let Some(prefix) = &self.title_prefix {
316 if !pane
317 .title
318 .as_deref()
319 .is_some_and(|title| title.starts_with(prefix))
320 {
321 return false;
322 }
323 }
324 if let Some(needle) = &self.command_contains {
325 if !pane
326 .command
327 .as_ref()
328 .is_some_and(|argv| argv.iter().any(|arg| arg.contains(needle)))
329 {
330 return false;
331 }
332 }
333 if let Some(needle) = &self.cwd_contains {
334 if !pane
335 .working_directory
336 .as_deref()
337 .is_some_and(|cwd| cwd.contains(needle))
338 {
339 return false;
340 }
341 }
342 if let Some(running) = self.running {
343 if !pane.process_matches(running) {
344 return false;
345 }
346 }
347 if let Some(window_index) = self.window_index {
348 if pane.window_index != window_index {
349 return false;
350 }
351 }
352 true
353 }
354}
355
356impl DiscoveredPane {
357 fn process_matches(&self, running: bool) -> bool {
358 matches!(
359 (running, &self.process),
360 (true, PaneProcessState::Running { .. }) | (false, PaneProcessState::Exited)
361 )
362 }
363}
364
365#[derive(Debug, Clone, Copy)]
366struct ListedPane {
367 window_index: u32,
368 pane_index: u32,
369 pane_id: PaneId,
370}
371
372async fn list_session_panes(session: &crate::Session) -> Result<Vec<ListedPane>> {
373 let response = session
374 .transport()
375 .request(Request::ListPanes(ListPanesRequest {
376 target: session.name().clone(),
377 target_window_index: None,
378 format: Some(PANE_DISCOVERY_FORMAT.to_owned()),
379 }))
380 .await?;
381
382 let output = match response {
383 Response::ListPanes(response) => response.output.stdout,
384 response => return Err(unexpected_response("list-panes", response)),
385 };
386
387 String::from_utf8_lossy(&output)
388 .lines()
389 .map(parse_listed_pane)
390 .collect()
391}
392
393fn parse_listed_pane(line: &str) -> Result<ListedPane> {
394 let mut fields = line.split(':');
395 let window_index = fields
396 .next()
397 .ok_or_else(|| parse_error("pane discovery line omitted window index"))?;
398 let pane_index = fields
399 .next()
400 .ok_or_else(|| parse_error("pane discovery line omitted pane index"))?;
401 let pane_id = fields
402 .next()
403 .ok_or_else(|| parse_error("pane discovery line omitted pane id"))?;
404 if fields.next().is_some() {
405 return Err(parse_error("pane discovery line had trailing fields"));
406 }
407 Ok(ListedPane {
408 window_index: parse_u32(window_index, "window index")?,
409 pane_index: parse_u32(pane_index, "pane index")?,
410 pane_id: parse_pane_id(pane_id)?,
411 })
412}
413
414fn parse_pane_id(value: &str) -> Result<PaneId> {
415 let raw = value
416 .strip_prefix('%')
417 .ok_or_else(|| parse_error(format!("pane id `{value}` omitted `%` prefix")))?;
418 parse_u32(raw, "pane id").map(PaneId::new)
419}
420
421fn parse_u32(value: &str, field: &str) -> Result<u32> {
422 value
423 .parse::<u32>()
424 .map_err(|error| parse_error(format!("invalid {field} `{value}`: {error}")))
425}
426
427fn strict_discovery_error(count: usize, filters: &PaneFilters) -> RmuxError {
428 RmuxError::protocol(rmux_proto::RmuxError::Server(format!(
429 "strict pane discovery violation: expected 1 match, found {count}; query: {}",
430 filters.describe()
431 )))
432}
433
434impl PaneFilters {
435 fn describe(&self) -> String {
436 let mut parts = Vec::new();
437 if let Some(session) = &self.session {
438 parts.push(format!("session={session}"));
439 }
440 if let Some(title) = &self.title {
441 parts.push(format!("title={title:?}"));
442 }
443 if let Some(prefix) = &self.title_prefix {
444 parts.push(format!("title_prefix={prefix:?}"));
445 }
446 if let Some(needle) = &self.command_contains {
447 parts.push(format!("command_contains={needle:?}"));
448 }
449 if let Some(needle) = &self.cwd_contains {
450 parts.push(format!("cwd_contains={needle:?}"));
451 }
452 if let Some(running) = self.running {
453 parts.push(
454 if running {
455 "running=true"
456 } else {
457 "exited=true"
458 }
459 .to_owned(),
460 );
461 }
462 if let Some(window_index) = self.window_index {
463 parts.push(format!("window_index={window_index}"));
464 }
465 if parts.is_empty() {
466 "<all panes>".to_owned()
467 } else {
468 parts.join(", ")
469 }
470 }
471}
472
473fn parse_error(message: impl Into<String>) -> RmuxError {
474 RmuxError::protocol(rmux_proto::RmuxError::Server(message.into()))
475}