Skip to main content

rmux_sdk/
extract.rs

1//! SDK-only pane extraction helpers.
2//!
3//! This module keeps the public extraction surface on the SDK side of the
4//! daemon boundary. Raw helpers collect bytes from the pane-output stream,
5//! while text helpers search rendered snapshot lines produced from structured
6//! cells. There is deliberately no core or daemon regex search API here.
7
8use crate::{Pane, PaneExitState, PaneOutputChunk, PaneOutputStart, PaneSnapshot, Result};
9
10const COLLECT_OUTPUT_UNTIL_EXIT_OPERATION: &str = "collect pane output until exit";
11
12/// Raw pane output collected while waiting for a pane process to exit.
13#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
14pub struct CollectedPaneOutput {
15    /// Raw bytes emitted by the pane after collection started.
16    ///
17    /// The bytes are copied directly from [`PaneOutputChunk::Bytes`] items and
18    /// are capped by the caller-supplied byte limit.
19    pub bytes: Vec<u8>,
20    /// Exit details observed after the output stream closed.
21    ///
22    /// `None` means the pane slot was already stale or vanished before the
23    /// daemon could expose retained exit metadata.
24    pub exit_state: Option<PaneExitState>,
25    /// Whether output bytes were dropped because `bytes` reached the supplied
26    /// byte limit.
27    pub truncated: bool,
28    /// Whether the underlying output stream reported at least one lag gap.
29    pub lagged: bool,
30    /// Total missed output events reported by lag notices.
31    pub missed_events: u64,
32}
33
34impl CollectedPaneOutput {
35    /// Returns the number of raw bytes retained in [`Self::bytes`].
36    #[must_use]
37    pub fn len(&self) -> usize {
38        self.bytes.len()
39    }
40
41    /// Returns whether no raw bytes were retained.
42    #[must_use]
43    pub fn is_empty(&self) -> bool {
44        self.bytes.is_empty()
45    }
46}
47
48/// A literal rendered-text match in a pane snapshot.
49#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub struct PaneTextMatch {
51    /// Zero-based visible row where the match starts.
52    pub start_row: u16,
53    /// Zero-based visible column where the match starts.
54    pub start_col: u16,
55    /// Zero-based visible row where the match ends.
56    pub end_row: u16,
57    /// Exclusive visible column where the match ends.
58    pub end_col: u16,
59    /// The rendered text that matched.
60    pub text: String,
61}
62
63impl PaneTextMatch {
64    fn new(row: u16, start_col: u16, end_col: u16, text: String) -> Self {
65        Self {
66            start_row: row,
67            start_col,
68            end_row: row,
69            end_col,
70            text,
71        }
72    }
73}
74
75impl PaneSnapshot {
76    /// Finds the first literal `needle` match in this snapshot's rendered
77    /// visible text.
78    ///
79    /// Search is line-local and uses the same lossy rendered rows as
80    /// [`Self::visible_lines`]: padding cells are skipped and trailing ASCII
81    /// spaces are trimmed. Match coordinates are visible grid positions; a
82    /// match that starts inside a wide glyph reports that glyph's owning
83    /// cell column. Empty needles return `None`.
84    #[must_use]
85    pub fn find_text(&self, needle: impl AsRef<str>) -> Option<PaneTextMatch> {
86        self.find_text_all(needle).into_iter().next()
87    }
88
89    /// Finds all literal `needle` matches in this snapshot's rendered visible
90    /// text.
91    ///
92    /// See [`Self::find_text`] for rendering and coordinate semantics.
93    #[must_use]
94    pub fn find_text_all(&self, needle: impl AsRef<str>) -> Vec<PaneTextMatch> {
95        find_text_in_snapshot(self, needle.as_ref())
96    }
97}
98
99pub(crate) async fn find_text(pane: &Pane, needle: String) -> Result<Option<PaneTextMatch>> {
100    Ok(pane.snapshot().await?.find_text(needle))
101}
102
103pub(crate) async fn find_text_all(pane: &Pane, needle: String) -> Result<Vec<PaneTextMatch>> {
104    Ok(pane.snapshot().await?.find_text_all(needle))
105}
106
107pub(crate) async fn collect_output_until_exit(
108    pane: &Pane,
109    max_bytes: usize,
110) -> Result<CollectedPaneOutput> {
111    collect_output_until_exit_starting_at(pane, PaneOutputStart::Now, max_bytes).await
112}
113
114pub(crate) async fn collect_output_until_exit_starting_at(
115    pane: &Pane,
116    start: PaneOutputStart,
117    max_bytes: usize,
118) -> Result<CollectedPaneOutput> {
119    let timeout = crate::wait::resolved_wait_timeout(pane.configured_default_timeout());
120    crate::wait::with_wait_timeout(
121        COLLECT_OUTPUT_UNTIL_EXIT_OPERATION,
122        timeout,
123        collect_output_until_exit_without_timeout(pane, start, max_bytes),
124    )
125    .await
126}
127
128async fn collect_output_until_exit_without_timeout(
129    pane: &Pane,
130    start: PaneOutputStart,
131    max_bytes: usize,
132) -> Result<CollectedPaneOutput> {
133    let mut collection = CollectedPaneOutput::default();
134    let mut stream = match pane.output_stream_starting_at(start).await {
135        Ok(stream) => stream,
136        Err(error) if crate::handles::is_already_closed_pane_error(&error, pane.target()) => {
137            collection.exit_state = exit_state_after_stream_close(pane).await?;
138            return Ok(collection);
139        }
140        Err(error) => return Err(error),
141    };
142
143    if let crate::wait::PaneExitObservation::Exited(exit_state) =
144        crate::wait::pane_exit_observation(pane).await?
145    {
146        drain_until_output_eof_or_close(&mut stream, &mut collection, max_bytes).await?;
147        collection.exit_state = exit_state;
148        return Ok(collection);
149    }
150
151    loop {
152        let saw_ready_output =
153            poll_ready_output_once(&mut stream, &mut collection, max_bytes).await?;
154        if saw_ready_output.saw_eof {
155            collection.exit_state = exit_state_after_stream_close(pane).await?;
156            return Ok(collection);
157        }
158        if let crate::wait::PaneExitObservation::Exited(exit_state) =
159            crate::wait::pane_exit_observation(pane).await?
160        {
161            drain_until_output_eof_or_close(&mut stream, &mut collection, max_bytes).await?;
162            collection.exit_state = exit_state;
163            return Ok(collection);
164        }
165        if !saw_ready_output.saw_output {
166            tokio::time::sleep(crate::wait::TEXT_POLL_INTERVAL).await;
167        }
168    }
169}
170
171async fn drain_until_output_eof_or_close(
172    stream: &mut crate::PaneOutputStream,
173    collection: &mut CollectedPaneOutput,
174    max_bytes: usize,
175) -> Result<()> {
176    loop {
177        match stream.next().await? {
178            Some(chunk) => {
179                if ingest_chunk(collection, chunk, max_bytes) {
180                    return Ok(());
181                }
182            }
183            None => return Ok(()),
184        }
185    }
186}
187
188#[derive(Debug, Clone, Copy)]
189struct ReadyOutputPoll {
190    saw_output: bool,
191    saw_eof: bool,
192}
193
194async fn poll_ready_output_once(
195    stream: &mut crate::PaneOutputStream,
196    collection: &mut CollectedPaneOutput,
197    max_bytes: usize,
198) -> Result<ReadyOutputPoll> {
199    let chunks = stream.poll_once().await?;
200    let saw_ready_output = !chunks.is_empty();
201    let mut saw_eof = false;
202    for chunk in chunks {
203        saw_eof |= ingest_chunk(collection, chunk, max_bytes);
204    }
205    Ok(ReadyOutputPoll {
206        saw_output: saw_ready_output,
207        saw_eof,
208    })
209}
210
211fn ingest_chunk(
212    collection: &mut CollectedPaneOutput,
213    chunk: PaneOutputChunk,
214    max_bytes: usize,
215) -> bool {
216    match chunk {
217        PaneOutputChunk::Bytes { bytes, .. } => {
218            if bytes.is_empty() {
219                return true;
220            }
221            collection.truncated |= extend_capped(&mut collection.bytes, &bytes, max_bytes);
222            false
223        }
224        PaneOutputChunk::Lag(notice) => {
225            collection.lagged = true;
226            collection.missed_events = collection
227                .missed_events
228                .saturating_add(notice.missed_events);
229            false
230        }
231    }
232}
233
234async fn exit_state_after_stream_close(pane: &Pane) -> Result<Option<PaneExitState>> {
235    loop {
236        match crate::wait::pane_exit_observation(pane).await? {
237            crate::wait::PaneExitObservation::Running => {
238                tokio::time::sleep(crate::wait::TEXT_POLL_INTERVAL).await;
239            }
240            crate::wait::PaneExitObservation::Exited(exit_state) => return Ok(exit_state),
241        }
242    }
243}
244
245fn extend_capped(target: &mut Vec<u8>, bytes: &[u8], max_bytes: usize) -> bool {
246    let remaining = max_bytes.saturating_sub(target.len());
247    if remaining >= bytes.len() {
248        target.extend_from_slice(bytes);
249        false
250    } else {
251        target.extend_from_slice(&bytes[..remaining]);
252        true
253    }
254}
255
256fn find_text_in_snapshot(snapshot: &PaneSnapshot, needle: &str) -> Vec<PaneTextMatch> {
257    if needle.is_empty() {
258        return Vec::new();
259    }
260
261    let lines = snapshot.visible_lines();
262    let mut matches = Vec::new();
263    for (row, line) in lines.iter().enumerate() {
264        let row = row as u16;
265        for (start, end) in literal_match_ranges(line, needle) {
266            if let Some(text_match) =
267                text_match_for_rendered_row_range(snapshot, row, line, start, end)
268            {
269                matches.push(text_match);
270            }
271        }
272    }
273    matches
274}
275
276pub(crate) fn text_match_for_rendered_row_range(
277    snapshot: &PaneSnapshot,
278    row: u16,
279    line: &str,
280    start: usize,
281    end: usize,
282) -> Option<PaneTextMatch> {
283    let coords = rendered_row_byte_coords(snapshot, row, line);
284    let start_coord = coords.get(start)?;
285    let end_coord = end.checked_sub(1).and_then(|index| coords.get(index))?;
286    Some(PaneTextMatch::new(
287        row,
288        start_coord.start_col,
289        end_coord.end_col,
290        line.get(start..end)?.to_owned(),
291    ))
292}
293
294#[derive(Debug, Clone, Copy)]
295struct ByteCoord {
296    start_col: u16,
297    end_col: u16,
298}
299
300fn rendered_row_byte_coords(snapshot: &PaneSnapshot, row: u16, line: &str) -> Vec<ByteCoord> {
301    let mut coords = Vec::new();
302    for col in 0..snapshot.cols {
303        let Some(cell) = snapshot.cell(row, col) else {
304            break;
305        };
306        if cell.is_padding() {
307            continue;
308        }
309
310        let Some(owner_col) = snapshot.owning_cell_col(row, col) else {
311            continue;
312        };
313        let end_col = owner_col
314            .saturating_add(u16::from(cell.glyph.width.max(1)))
315            .min(snapshot.cols);
316        coords.extend(cell.text().bytes().map(|_| ByteCoord {
317            start_col: owner_col,
318            end_col,
319        }));
320    }
321    coords.truncate(line.len());
322    coords
323}
324
325fn literal_match_ranges(haystack: &str, needle: &str) -> Vec<(usize, usize)> {
326    let mut ranges = Vec::new();
327    let mut search_start = 0;
328    while search_start <= haystack.len() {
329        let Some(relative) = haystack[search_start..].find(needle) else {
330            break;
331        };
332        let start = search_start + relative;
333        let end = start + needle.len();
334        ranges.push((start, end));
335        search_start = next_char_boundary_after(haystack, start);
336    }
337    ranges
338}
339
340fn next_char_boundary_after(value: &str, index: usize) -> usize {
341    value[index..]
342        .chars()
343        .next()
344        .map_or(value.len() + 1, |character| index + character.len_utf8())
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::{PaneCell, PaneCursor, PaneGlyph};
351
352    fn cell(text: &str) -> PaneCell {
353        PaneCell::new(PaneGlyph::new(text, 1))
354    }
355
356    fn wide(text: &str, width: u8) -> PaneCell {
357        PaneCell::new(PaneGlyph::new(text, width))
358    }
359
360    #[test]
361    fn capped_extend_never_exceeds_limit() {
362        let mut bytes = b"abc".to_vec();
363        assert!(extend_capped(&mut bytes, b"def", 5));
364        assert_eq!(bytes, b"abcde");
365        assert!(extend_capped(&mut bytes, b"g", 5));
366        assert_eq!(bytes, b"abcde");
367    }
368
369    #[test]
370    fn capped_extend_handles_zero_limit() {
371        let mut bytes = Vec::new();
372        assert!(extend_capped(&mut bytes, b"abc", 0));
373        assert!(bytes.is_empty());
374    }
375
376    #[test]
377    fn literal_ranges_include_overlapping_matches() {
378        assert_eq!(
379            literal_match_ranges("aaaa", "aa"),
380            vec![(0, 2), (1, 3), (2, 4)]
381        );
382    }
383
384    #[test]
385    fn find_text_uses_visible_lines_and_wide_cell_owner_columns() {
386        let snapshot = PaneSnapshot::new(
387            6,
388            1,
389            vec![
390                cell("A"),
391                wide("界", 2),
392                PaneCell::padding(),
393                cell("B"),
394                cell(" "),
395                cell(" "),
396            ],
397            PaneCursor::default(),
398        )
399        .expect("valid snapshot");
400
401        let matches = snapshot.find_text_all("界B");
402        assert_eq!(matches.len(), 1);
403        assert_eq!(matches[0].start_row, 0);
404        assert_eq!(matches[0].start_col, 1);
405        assert_eq!(matches[0].end_col, 4);
406        assert_eq!(matches[0].text, "界B");
407    }
408
409    #[test]
410    fn find_text_clamps_malformed_wide_match_end_to_visible_width() {
411        let snapshot = PaneSnapshot::new(
412            3,
413            1,
414            vec![cell("A"), wide("界", 4), PaneCell::padding()],
415            PaneCursor::default(),
416        )
417        .expect("valid snapshot");
418
419        let text_match = snapshot.find_text("界").expect("wide match found");
420        assert_eq!(text_match.start_col, 1);
421        assert_eq!(text_match.end_col, 3);
422    }
423
424    #[test]
425    fn find_text_returns_none_for_empty_needle() {
426        let snapshot = PaneSnapshot::new(
427            3,
428            1,
429            vec![cell("a"), cell("b"), cell("c")],
430            PaneCursor::default(),
431        )
432        .expect("valid snapshot");
433
434        assert!(snapshot.find_text("").is_none());
435        assert!(snapshot.find_text_all("").is_empty());
436    }
437
438    #[test]
439    fn find_text_returns_none_on_default_snapshot() {
440        let snapshot = PaneSnapshot::default();
441        assert!(snapshot.find_text("anything").is_none());
442        assert!(snapshot.find_text_all("anything").is_empty());
443    }
444
445    #[test]
446    fn find_text_returns_none_when_needle_exceeds_visible_text() {
447        let snapshot = PaneSnapshot::new(2, 1, vec![cell("a"), cell("b")], PaneCursor::default())
448            .expect("valid snapshot");
449
450        assert!(snapshot.find_text("abc").is_none());
451    }
452
453    #[test]
454    fn capped_extend_marks_truncated_when_zero_limit_meets_nonempty_bytes() {
455        let mut bytes = Vec::new();
456        // Empty input at zero cap is not truncation, but any non-empty input at
457        // zero cap must report truncation so callers can distinguish empty-data
458        // exits from cap-limited collections.
459        assert!(!extend_capped(&mut bytes, b"", 0));
460        assert!(extend_capped(&mut bytes, b"x", 0));
461        assert!(bytes.is_empty());
462    }
463}