Skip to main content

rmux_sdk/
discovery.rs

1//! SDK-side discovery helpers for existing rmux-managed terminals.
2//!
3//! Discovery is deliberately an inventory layer, not a metadata database.
4//! It queries the daemon's current session and pane lists, enriches matches
5//! through existing pane info/title accessors, and returns regular [`Pane`]
6//! handles that callers can use immediately.
7//!
8//! P3 discovery is SDK-side and performs one or more daemon round-trips per
9//! discovered pane. Scope queries with [`PaneFinder::session`] when possible
10//! for large workspaces.
11
12use 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/// One pane discovered from the daemon's current inventory.
24#[derive(Debug, Clone)]
25pub struct DiscoveredPane {
26    /// Owning session name.
27    pub session_name: SessionName,
28    /// Owning session id.
29    pub session_id: SessionId,
30    /// Owning window id.
31    pub window_id: WindowId,
32    /// Current window index inside the owning session.
33    pub window_index: u32,
34    /// Stable daemon pane id.
35    pub pane_id: PaneId,
36    /// Current pane index inside the owning window.
37    pub pane_index: u32,
38    /// Current pane title, when the daemon exposes one.
39    pub title: Option<String>,
40    /// Spawned process argv recorded by the daemon.
41    pub command: Option<Vec<String>>,
42    /// Process working directory recorded by the daemon.
43    pub working_directory: Option<String>,
44    /// Tags recorded on the pane/window/session info surfaces.
45    pub tags: Vec<String>,
46    /// Current pane process state.
47    pub process: PaneProcessState,
48    /// Stable pane handle addressed by pane id.
49    pub pane: Pane,
50}
51
52/// One session discovered from the daemon inventory.
53#[derive(Debug)]
54pub struct DiscoveredSession {
55    /// Session name.
56    pub name: SessionName,
57    /// Live session handle.
58    pub session: Session,
59}
60
61/// Query builder for rmux-managed sessions.
62#[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    /// Restricts discovery to one exact session name.
75    pub fn name(mut self, name: impl AsRef<str>) -> Self {
76        self.name = Some(name.as_ref().to_owned());
77        self
78    }
79
80    /// Returns every session matching this query.
81    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    /// Returns the single session matching this query.
102    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/// Query builder for panes already managed by rmux.
118#[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    /// Restricts discovery to one session.
145    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    /// Restricts discovery to panes with this exact title.
151    pub fn title(mut self, title: impl Into<String>) -> Self {
152        self.filters.title = Some(title.into());
153        self
154    }
155
156    /// Restricts discovery to panes whose title starts with `prefix`.
157    pub fn title_prefix(mut self, prefix: impl Into<String>) -> Self {
158        self.filters.title_prefix = Some(prefix.into());
159        self
160    }
161
162    /// Restricts discovery to panes whose recorded argv contains `needle`.
163    pub fn command_contains(mut self, needle: impl Into<String>) -> Self {
164        self.filters.command_contains = Some(needle.into());
165        self
166    }
167
168    /// Restricts discovery to panes whose recorded cwd contains `needle`.
169    pub fn cwd_contains(mut self, needle: impl Into<String>) -> Self {
170        self.filters.cwd_contains = Some(needle.into());
171        self
172    }
173
174    /// Restricts discovery to one window index.
175    pub const fn window_index(mut self, index: u32) -> Self {
176        self.filters.window_index = Some(index);
177        self
178    }
179
180    /// Restricts discovery to panes whose process is currently running.
181    pub const fn running(mut self) -> Self {
182        self.filters.running = Some(true);
183        self
184    }
185
186    /// Restricts discovery to panes whose process has exited.
187    pub const fn exited(mut self) -> Self {
188        self.filters.running = Some(false);
189        self
190    }
191
192    /// Returns every pane matching this query, deduplicated by session and pane id.
193    pub async fn all(self) -> Result<Vec<DiscoveredPane>> {
194        discover_panes(self.rmux, &self.filters).await
195    }
196
197    /// Returns the single pane matching this query.
198    ///
199    /// A zero-match or multi-match result is reported as a strict discovery
200    /// violation with the query summary in the diagnostic text.
201    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    /// Collects matching panes into a [`PaneSet`].
215    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}