Skip to main content

datui_lib/
clipboard.rs

1//! The system clipboard, reached two ways, and the shapes a copy takes.
2//!
3//! **native** talks to the display server through arboard. On Wayland the copy
4//! is owned by this process, so it survives only as long as datui runs unless a
5//! clipboard manager persists it. For tabular copies the native path offers two
6//! flavors at once — `text/html` (a real `<table>`) beside plain text — so a
7//! paste into a spreadsheet or an email lands as a table while a paste into a
8//! terminal stays TSV.
9//!
10//! **osc52** prints an `OSC 52` escape sequence for the terminal to act on,
11//! which is what works over SSH with no display server in sight. The sequence
12//! must go straight to stdout: the ratatui buffer is sanitized
13//! ([`crate::sanitize`]), and an escape drawn as cell text is an escape
14//! stripped. Terminals cap how much OSC 52 they accept, so the payload is
15//! capped here first, with the limit in the config where a generous terminal's
16//! user can raise it. The cap is known before a copy is built: a table copy to
17//! the terminal is read in batches and stops at the first byte over it, and no
18//! HTML flavor is built for a destination that cannot offer one.
19//!
20//! **auto** is native where it initializes and osc52 everywhere else, decided
21//! once per run at the first copy.
22
23use polars::prelude::*;
24use std::io::Write as _;
25
26/// Which clipboard mechanism `[clipboard] backend` asks for.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum BackendChoice {
29    #[default]
30    Auto,
31    Native,
32    Osc52,
33}
34
35impl BackendChoice {
36    pub fn parse(s: &str) -> Option<Self> {
37        match s.trim().to_ascii_lowercase().as_str() {
38            "auto" => Some(Self::Auto),
39            "native" => Some(Self::Native),
40            "osc52" => Some(Self::Osc52),
41            _ => None,
42        }
43    }
44}
45
46/// One copy, ready for whichever destination takes it. The text flavor is
47/// always there; the HTML flavor rides along when the copy is a table and the
48/// destination can offer both.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Payload {
51    pub text: String,
52    pub html: Option<String>,
53}
54
55impl Payload {
56    pub fn text(text: String) -> Self {
57        Self { text, html: None }
58    }
59}
60
61/// Somewhere a payload can go. A trait so the integration tests can hand the
62/// app a destination that only records what it was given.
63pub trait Destination {
64    /// Takes the payload: a destination that keeps it owns it, without a copy.
65    fn write(&mut self, payload: Payload) -> Result<(), String>;
66    /// One word for the flash and for errors: "clipboard" or "terminal".
67    fn describe(&self) -> &'static str;
68    fn accepts(&self) -> Accepts {
69        Accepts {
70            html: true,
71            base64_limit: None,
72        }
73    }
74}
75
76/// What a destination takes, known before a copy is built.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct Accepts {
79    /// Offers an HTML flavor beside the text.
80    pub html: bool,
81    /// The longest copy it takes, in bytes of base64; none for no cap.
82    pub base64_limit: Option<usize>,
83}
84
85/// arboard, kept alive for the life of the app: on Wayland and X11 the copy
86/// dies with the process that owns it, so dropping the handle early would
87/// revoke the copy the flash just announced.
88pub struct Native {
89    clipboard: arboard::Clipboard,
90}
91
92impl Native {
93    pub fn new() -> Result<Self, String> {
94        arboard::Clipboard::new()
95            .map(|clipboard| Self { clipboard })
96            .map_err(|e| format!("clipboard unavailable: {e}"))
97    }
98}
99
100impl Destination for Native {
101    fn write(&mut self, payload: Payload) -> Result<(), String> {
102        let result = match payload.html {
103            Some(html) => self.clipboard.set_html(html, Some(payload.text)),
104            None => self.clipboard.set_text(payload.text),
105        };
106        result.map_err(|e| format!("copy failed: {e}"))
107    }
108
109    fn describe(&self) -> &'static str {
110        "clipboard"
111    }
112}
113
114/// The `OSC 52` writer. Carries only the cap; stdout is fetched per write.
115pub struct Osc52 {
116    /// Longest base64 payload to attempt, in bytes.
117    pub limit: usize,
118}
119
120impl Destination for Osc52 {
121    fn write(&mut self, payload: Payload) -> Result<(), String> {
122        let sequence = osc52_sequence(&payload.text, self.limit)?;
123        // Encoded, the text is not needed while the sequence is written.
124        drop(payload);
125        let mut out = std::io::stdout();
126        out.write_all(sequence.as_bytes())
127            .and_then(|()| out.flush())
128            .map_err(|e| format!("copy failed: {e}"))
129    }
130
131    fn describe(&self) -> &'static str {
132        "terminal"
133    }
134
135    fn accepts(&self) -> Accepts {
136        Accepts {
137            html: false,
138            base64_limit: Some(self.limit),
139        }
140    }
141}
142
143/// The length of `bytes` bytes in padded base64.
144pub fn base64_len(bytes: usize) -> usize {
145    bytes.div_ceil(3).saturating_mul(4)
146}
147
148/// The escape sequence that asks the terminal to set the system clipboard,
149/// or why it was not built. Split from [`Osc52::write`] so a test can read
150/// the bytes without owning stdout. The size is checked before anything is
151/// encoded.
152pub fn osc52_sequence(text: &str, limit: usize) -> Result<String, String> {
153    use base64::Engine as _;
154    let encoded = base64_len(text.len());
155    if encoded > limit {
156        return Err(over_osc52_limit(Some(encoded), limit));
157    }
158    let mut sequence = String::with_capacity(encoded + 8);
159    sequence.push_str("\x1b]52;c;");
160    base64::engine::general_purpose::STANDARD.encode_string(text.as_bytes(), &mut sequence);
161    sequence.push('\x07');
162    Ok(sequence)
163}
164
165/// Why a copy does not go through the terminal. `encoded` is its size in base64,
166/// or none when it stopped being built at the cap.
167pub(crate) fn over_osc52_limit(encoded: Option<usize>, limit: usize) -> String {
168    let size = match encoded {
169        Some(bytes) => format_kb(bytes),
170        None => format!("over {}", format_kb(limit)),
171    };
172    format!(
173        "the copy is {size} of base64 and the terminal path is capped at {} \
174         (raise [clipboard] osc52_limit, or export to a file)",
175        format_kb(limit),
176    )
177}
178
179fn format_kb(bytes: usize) -> String {
180    format!("{} KB", bytes.div_ceil(1024))
181}
182
183/// The destination a backend choice names, built at the first copy.
184pub fn destination(
185    choice: BackendChoice,
186    osc52_limit: usize,
187) -> Result<Box<dyn Destination>, String> {
188    match choice {
189        BackendChoice::Native => Native::new().map(|n| Box::new(n) as Box<dyn Destination>),
190        BackendChoice::Osc52 => Ok(Box::new(Osc52 { limit: osc52_limit })),
191        BackendChoice::Auto => Ok(match Native::new() {
192            Ok(native) => Box::new(native),
193            // No display server to talk to — an SSH session — is exactly
194            // what the escape-sequence path is for.
195            Err(_) => Box::new(Osc52 { limit: osc52_limit }),
196        }),
197    }
198}
199
200// ----- The shapes a copy takes -----
201
202/// The formats the dialog offers for tabular scopes.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
204pub enum CopyFormat {
205    #[default]
206    Tsv,
207    Csv,
208    Markdown,
209}
210
211impl CopyFormat {
212    pub const ALL: [Self; 3] = [Self::Tsv, Self::Csv, Self::Markdown];
213
214    pub fn as_str(self) -> &'static str {
215        match self {
216            Self::Tsv => "TSV",
217            Self::Csv => "CSV",
218            Self::Markdown => "Markdown",
219        }
220    }
221}
222
223/// A DataFrame as delimited text, quoted the way spreadsheets parse a paste:
224/// only fields holding the delimiter, a quote or a newline are quoted, which
225/// is `QuoteStyle::Necessary`, the writer's default. Raw values, like export;
226/// a null is an empty field, never the UI's `∅`.
227pub fn delimited(df: &DataFrame, separator: u8, header: bool) -> Result<String, String> {
228    let mut out = Vec::new();
229    let mut df = crate::nested_json::frame_as_json(df).map_err(|e| e.to_string())?;
230    CsvWriter::new(&mut out)
231        .with_separator(separator)
232        .include_header(header)
233        .finish(&mut df)
234        .map_err(|e| e.to_string())?;
235    let mut text = String::from_utf8(out).map_err(|e| e.to_string())?;
236    // The writer ends the last record with a newline; a paste target treats
237    // that as an empty extra row.
238    while text.ends_with('\n') || text.ends_with('\r') {
239        text.pop();
240    }
241    Ok(text)
242}
243
244/// A DataFrame as a Markdown table. Always with the header: the delimiter row
245/// under it is what makes Markdown read the block as a table at all. Numeric
246/// columns declare right alignment, pipes are escaped, and embedded newlines
247/// flatten to spaces — a Markdown cell has no way to hold one.
248pub fn markdown(df: &DataFrame) -> Result<String, String> {
249    let mut layout = MarkdownLayout::new(df);
250    layout.measure(df)?;
251    let mut out = String::with_capacity(layout.len(df.height()));
252    layout.write(df, &mut out)?;
253    Ok(out)
254}
255
256/// A Markdown table's columns: their names, alignment and widths. Widths are
257/// measured over every row before a row is written, a cell at a time, so no
258/// cell's text is held beyond its own.
259struct MarkdownLayout {
260    names: Vec<String>,
261    numeric: Vec<bool>,
262    widths: Vec<usize>,
263}
264
265impl MarkdownLayout {
266    fn new(df: &DataFrame) -> Self {
267        let names: Vec<String> = df
268            .get_column_names()
269            .iter()
270            .map(|name| markdown_escape(name))
271            .collect();
272        let widths = names.iter().map(|n| n.chars().count().max(3)).collect();
273        let numeric = df
274            .columns()
275            .iter()
276            .map(|c| c.dtype().is_primitive_numeric())
277            .collect();
278        Self {
279            names,
280            numeric,
281            widths,
282        }
283    }
284
285    /// Widen the columns to fit the rows of `df`.
286    fn measure(&mut self, df: &DataFrame) -> Result<(), String> {
287        for (column, width) in df.columns().iter().zip(&mut self.widths) {
288            let series = column.as_materialized_series();
289            for row in 0..df.height() {
290                *width = (*width).max(markdown_cell(series, row)?.chars().count());
291            }
292        }
293        Ok(())
294    }
295
296    /// The table's length over `rows` rows at the widths measured so far, in
297    /// characters: no more than its bytes, and only ever growing as rows widen
298    /// the columns.
299    fn len(&self, rows: usize) -> usize {
300        let line = self.widths.iter().sum::<usize>() + 3 * self.widths.len() + 1;
301        (rows + 2) * line + rows + 1
302    }
303
304    fn write_line<'a>(&self, out: &mut String, cells: impl Iterator<Item = &'a str>) {
305        out.push_str("| ");
306        for (i, ((cell, &width), &right)) in cells.zip(&self.widths).zip(&self.numeric).enumerate()
307        {
308            if i > 0 {
309                out.push_str(" | ");
310            }
311            // A numeric column is padded to the right, so the raw text reads the
312            // way the `---:` delimiter tells a renderer to draw it; the two agree.
313            let fill = width - cell.chars().count();
314            if right {
315                out.extend(std::iter::repeat_n(' ', fill));
316                out.push_str(cell);
317            } else {
318                out.push_str(cell);
319                out.extend(std::iter::repeat_n(' ', fill));
320            }
321        }
322        out.push_str(" |");
323    }
324
325    /// Append the header, the delimiter row and the rows of `df`.
326    fn write(&self, df: &DataFrame, out: &mut String) -> Result<(), String> {
327        self.write_line(out, self.names.iter().map(String::as_str));
328        out.push_str("\n|");
329        for (i, (&width, &right)) in self.widths.iter().zip(&self.numeric).enumerate() {
330            if i > 0 {
331                out.push('|');
332            }
333            out.push(' ');
334            if right {
335                out.extend(std::iter::repeat_n('-', width.saturating_sub(1)));
336                out.push(':');
337            } else {
338                out.extend(std::iter::repeat_n('-', width));
339            }
340            out.push(' ');
341        }
342        out.push('|');
343        self.write_rows(df, out)
344    }
345
346    /// Append the rows of `df`, each on a line of its own.
347    fn write_rows(&self, df: &DataFrame, out: &mut String) -> Result<(), String> {
348        let series: Vec<&Series> = df
349            .columns()
350            .iter()
351            .map(Column::as_materialized_series)
352            .collect();
353        let mut cells = Vec::with_capacity(series.len());
354        for row in 0..df.height() {
355            cells.clear();
356            for s in &series {
357                cells.push(markdown_cell(s, row)?);
358            }
359            out.push('\n');
360            self.write_line(out, cells.iter().map(String::as_str));
361        }
362        Ok(())
363    }
364}
365
366fn markdown_escape(s: &str) -> String {
367    s.replace('|', "\\|").replace(['\n', '\r'], " ")
368}
369
370/// One Markdown cell: the value escaped, a null empty.
371fn markdown_cell(series: &Series, row: usize) -> Result<String, String> {
372    Ok(match series.get(row).map_err(|e| e.to_string())? {
373        AnyValue::Null => String::new(),
374        // Exact, as the TSV and CSV writers are: Polars' own display would
375        // round a float to its compact preview.
376        v => markdown_escape(&crate::exact::value_text(&v)),
377    })
378}
379
380/// A DataFrame as an HTML table, the rich flavor beside a TSV or CSV copy.
381/// Everything is escaped; a null is an empty cell.
382pub fn html_table(df: &DataFrame, header: bool) -> Result<String, String> {
383    let escape = |s: &str| {
384        s.replace('&', "&amp;")
385            .replace('<', "&lt;")
386            .replace('>', "&gt;")
387    };
388    let column_names = df.get_column_names_owned();
389    let mut out = String::from("<table>");
390    if header {
391        out.push_str("<thead><tr>");
392        for name in &column_names {
393            out.push_str(&format!("<th>{}</th>", escape(name)));
394        }
395        out.push_str("</tr></thead>");
396    }
397    out.push_str("<tbody>");
398    for row in 0..df.height() {
399        out.push_str("<tr>");
400        for name in &column_names {
401            let value = df
402                .column(name)
403                .map_err(|e| e.to_string())?
404                .as_materialized_series()
405                .get(row)
406                .map_err(|e| e.to_string())?;
407            let text = match value {
408                AnyValue::Null => String::new(),
409                v => crate::exact::value_text(&v),
410            };
411            out.push_str(&format!("<td>{}</td>", escape(&text)));
412        }
413        out.push_str("</tr>");
414    }
415    out.push_str("</tbody></table>");
416    Ok(out)
417}
418
419/// The payload for a tabular copy: the chosen format as text, with the HTML
420/// flavor beside a TSV or CSV copy when `html` asks for it (the destination can
421/// offer it). A Markdown copy is the Markdown itself — pasting rich HTML where
422/// Markdown was asked for would defeat the choice. List and struct cells are
423/// JSON in every format, as in a CSV export.
424pub fn tabular_payload(
425    df: &DataFrame,
426    format: CopyFormat,
427    header: bool,
428    html: bool,
429) -> Result<Payload, String> {
430    let df = &crate::nested_json::frame_as_cells(df).map_err(|e| e.to_string())?;
431    let text = match format {
432        CopyFormat::Tsv => delimited(df, b'\t', header)?,
433        CopyFormat::Csv => delimited(df, b',', header)?,
434        CopyFormat::Markdown => markdown(df)?,
435    };
436    let html = match format {
437        CopyFormat::Tsv | CopyFormat::Csv if html => Some(html_table(df, header)?),
438        _ => None,
439    };
440    Ok(Payload { text, html })
441}
442
443/// Rows a table copy to a capped destination is read in: what runs past the cap
444/// is at most this many rows.
445const BOUNDED_BATCH_ROWS: usize = 1024;
446
447/// The text of a table copy and its row count, read from `lf` a batch at a time
448/// and given up at the first batch that takes it past `limit` bytes of base64:
449/// a copy the terminal will not take is never read or written whole. Text only;
450/// a capped destination offers no HTML flavor.
451///
452/// What the query does upstream of its last rows (a sort, a join) still takes
453/// its own memory; a Markdown copy keeps its rows until the widths are known,
454/// which the cap bounds as it does the text.
455pub fn bounded_table_text(
456    lf: LazyFrame,
457    format: CopyFormat,
458    header: bool,
459    limit: usize,
460) -> Result<(String, usize), String> {
461    use std::sync::{Arc, Mutex};
462    let polars_error = |e: PolarsError| crate::error_display::user_message_from_polars(&e);
463    let schema = lf.clone().collect_schema().map_err(polars_error)?;
464    let state = Arc::new(Mutex::new(BoundedText::new(format, header, limit)));
465    let sink_state = Arc::clone(&state);
466    let sink = lf
467        .sink_batches(
468            PlanCallback::new(move |batch: DataFrame| {
469                let mut text = sink_state
470                    .lock()
471                    .map_err(|_| PolarsError::ComputeError("copy lock failed".into()))?;
472                // True stops the read: the copy is over the cap, or failed.
473                Ok(text.take(batch))
474            }),
475            true,
476            std::num::NonZeroUsize::new(BOUNDED_BATCH_ROWS),
477        )
478        .map_err(polars_error)?;
479    // Streaming whatever the setting: the in-memory engine collects the whole result
480    // before the first batch, which is what stopping at the cap is here to avoid.
481    crate::statistics::collect_lazy(sink, true).map_err(polars_error)?;
482    let mut text = std::mem::replace(
483        &mut *state.lock().map_err(|_| "copy lock failed".to_string())?,
484        BoundedText::new(format, header, limit),
485    );
486    if !text.started {
487        // No batch came: the header alone, from the schema.
488        text.take(DataFrame::empty_with_schema(&schema));
489    }
490    text.finish()
491}
492
493/// A table copy's text as its batches come in, against the cap.
494struct BoundedText {
495    format: CopyFormat,
496    header: bool,
497    limit: usize,
498    started: bool,
499    text: String,
500    rows: usize,
501    /// A Markdown copy's columns and the rows to write once they are measured.
502    markdown: Option<(MarkdownLayout, Vec<DataFrame>)>,
503    over: bool,
504    error: Option<String>,
505}
506
507impl BoundedText {
508    fn new(format: CopyFormat, header: bool, limit: usize) -> Self {
509        Self {
510            format,
511            header,
512            limit,
513            started: false,
514            text: String::new(),
515            rows: 0,
516            markdown: None,
517            over: false,
518            error: None,
519        }
520    }
521
522    /// Add a batch; true once the copy cannot go on.
523    fn take(&mut self, batch: DataFrame) -> bool {
524        if self.over || self.error.is_some() {
525            return true;
526        }
527        if let Err(e) = self.try_take(batch) {
528            self.error = Some(e);
529        }
530        self.over || self.error.is_some()
531    }
532
533    fn try_take(&mut self, batch: DataFrame) -> Result<(), String> {
534        let first = !self.started;
535        self.started = true;
536        self.rows += batch.height();
537        let batch = crate::nested_json::frame_as_cells(&batch).map_err(|e| e.to_string())?;
538        let separator = match self.format {
539            CopyFormat::Tsv => b'\t',
540            CopyFormat::Csv => b',',
541            CopyFormat::Markdown => {
542                let (layout, rows) = self
543                    .markdown
544                    .get_or_insert_with(|| (MarkdownLayout::new(&batch), Vec::new()));
545                layout.measure(&batch)?;
546                // The widths so far are a floor on the final ones.
547                self.over = base64_len(layout.len(self.rows)) > self.limit;
548                rows.push(batch);
549                return Ok(());
550            }
551        };
552        let mut out = Vec::new();
553        let mut batch = crate::nested_json::frame_as_json(&batch).map_err(|e| e.to_string())?;
554        CsvWriter::new(&mut out)
555            .with_separator(separator)
556            .include_header(first && self.header)
557            .finish(&mut batch)
558            .map_err(|e| e.to_string())?;
559        self.text
560            .push_str(&String::from_utf8(out).map_err(|e| e.to_string())?);
561        // The last record's newline is trimmed at the end.
562        self.over = base64_len(self.text.len().saturating_sub(1)) > self.limit;
563        Ok(())
564    }
565
566    fn finish(mut self) -> Result<(String, usize), String> {
567        if let Some(e) = self.error {
568            return Err(e);
569        }
570        if self.over {
571            return Err(over_osc52_limit(None, self.limit));
572        }
573        if let Some((layout, frames)) = self.markdown.take() {
574            self.text.reserve(layout.len(self.rows));
575            let mut frames = frames.iter();
576            if let Some(first) = frames.next() {
577                layout.write(first, &mut self.text)?;
578            }
579            for frame in frames {
580                layout.write_rows(frame, &mut self.text)?;
581            }
582        }
583        while self.text.ends_with('\n') || self.text.ends_with('\r') {
584            self.text.pop();
585        }
586        let encoded = base64_len(self.text.len());
587        if encoded > self.limit {
588            return Err(over_osc52_limit(Some(encoded), self.limit));
589        }
590        Ok((self.text, self.rows))
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    /// The Markdown writer as it was, holding every cell, with each value as
599    /// exact text: what the bounded one must write, byte for byte.
600    fn markdown_reference(df: &DataFrame) -> Result<String, String> {
601        let column_names = df.get_column_names_owned();
602        let escape = |s: &str| s.replace('|', "\\|").replace(['\n', '\r'], " ");
603        let mut names: Vec<String> = Vec::with_capacity(column_names.len());
604        let mut cells: Vec<Vec<String>> = Vec::with_capacity(column_names.len());
605        let mut numeric: Vec<bool> = Vec::with_capacity(column_names.len());
606        for name in &column_names {
607            let column = df.column(name).map_err(|e| e.to_string())?;
608            names.push(escape(name));
609            numeric.push(column.dtype().is_primitive_numeric());
610            let series = column.as_materialized_series();
611            let mut body = Vec::with_capacity(df.height());
612            for i in 0..df.height() {
613                let value = series.get(i).map_err(|e| e.to_string())?;
614                body.push(match value {
615                    AnyValue::Null => String::new(),
616                    v => escape(&crate::exact::value_text(&v)),
617                });
618            }
619            cells.push(body);
620        }
621        let widths: Vec<usize> = names
622            .iter()
623            .zip(&cells)
624            .map(|(name, body)| {
625                body.iter()
626                    .map(|c| c.chars().count())
627                    .max()
628                    .unwrap_or(0)
629                    .max(name.chars().count())
630                    .max(3)
631            })
632            .collect();
633        // A numeric column is padded to the right, so the raw text reads the way
634        // the `---:` delimiter tells a renderer to draw it; the two agree.
635        let pad = |s: &str, w: usize, right: bool| {
636            let fill = " ".repeat(w - s.chars().count());
637            if right {
638                format!("{fill}{s}")
639            } else {
640                format!("{s}{fill}")
641            }
642        };
643        let mut lines = Vec::with_capacity(df.height() + 2);
644        lines.push(format!(
645            "| {} |",
646            names
647                .iter()
648                .zip(&widths)
649                .zip(&numeric)
650                .map(|((n, &w), &num)| pad(n, w, num))
651                .collect::<Vec<_>>()
652                .join(" | ")
653        ));
654        lines.push(format!(
655            "|{}|",
656            widths
657                .iter()
658                .zip(&numeric)
659                .map(|(&w, &num)| {
660                    if num {
661                        format!(" {}: ", "-".repeat(w.saturating_sub(1)))
662                    } else {
663                        format!(" {} ", "-".repeat(w))
664                    }
665                })
666                .collect::<Vec<_>>()
667                .join("|")
668        ));
669        for row in 0..df.height() {
670            lines.push(format!(
671                "| {} |",
672                cells
673                    .iter()
674                    .zip(&widths)
675                    .zip(&numeric)
676                    .map(|((body, &w), &num)| pad(&body[row], w, num))
677                    .collect::<Vec<_>>()
678                    .join(" | ")
679            ));
680        }
681        Ok(lines.join("\n"))
682    }
683
684    fn tricky() -> DataFrame {
685        df!(
686            "name" => ["plain", "tab\there", "pipe|and\nnewline", "\"quoted\""],
687            "n" => [Some(1i64), Some(2), None, Some(4)],
688        )
689        .unwrap()
690    }
691
692    #[test]
693    fn tsv_quotes_only_what_a_paste_needs_quoted() {
694        let text = delimited(&tricky(), b'\t', true).unwrap();
695        let lines: Vec<&str> = text.split('\n').collect();
696        assert_eq!(lines[0], "name\tn");
697        assert_eq!(lines[1], "plain\t1");
698        // The embedded tab and newline are quoted, so a spreadsheet reads one cell.
699        assert_eq!(lines[2], "\"tab\there\"\t2");
700        assert!(text.contains("\"pipe|and\nnewline\"\t"));
701        // A null is an empty field, and nothing here is the UI's null glyph.
702        assert!(!text.contains('∅'));
703        assert!(text.ends_with("\"\"\"quoted\"\"\"\t4"), "{text:?}");
704    }
705
706    #[test]
707    fn header_toggle_is_honored() {
708        let with = delimited(&tricky(), b',', true).unwrap();
709        let without = delimited(&tricky(), b',', false).unwrap();
710        assert!(with.starts_with("name,n"));
711        assert!(without.starts_with("plain,1"));
712    }
713
714    #[test]
715    fn markdown_escapes_aligns_and_keeps_nulls_empty() {
716        let text = markdown(&tricky()).unwrap();
717        let lines: Vec<&str> = text.split('\n').collect();
718        assert!(lines[0].starts_with("| name"));
719        // The numeric column's delimiter declares right alignment, and the raw
720        // text pads its header and cells the same way, so the two agree.
721        assert!(lines[1].contains("-: |"), "{}", lines[1]);
722        assert!(lines[0].ends_with("|   n |"), "{}", lines[0]);
723        assert!(lines[2].ends_with("|   1 |"), "{}", lines[2]);
724        assert!(text.contains("pipe\\|and newline"), "{text}");
725        // Every row spans the same padded width.
726        let width = lines[0].chars().count();
727        assert!(lines.iter().all(|l| l.chars().count() == width), "{text}");
728    }
729
730    #[test]
731    fn html_flavor_escapes_and_rides_beside_tsv_only() {
732        let payload = tabular_payload(&tricky(), CopyFormat::Tsv, true, true).unwrap();
733        let html = payload.html.expect("tsv carries the html flavor");
734        assert!(html.starts_with("<table><thead>"));
735        assert!(html.contains("<td>\"quoted\"</td>"));
736        let md = tabular_payload(&tricky(), CopyFormat::Markdown, true, true).unwrap();
737        assert!(md.html.is_none(), "markdown is its own rich flavor");
738        let text_only = tabular_payload(&tricky(), CopyFormat::Csv, true, false).unwrap();
739        assert!(text_only.html.is_none(), "not built where it cannot go");
740        assert_eq!(text_only.text, delimited(&tricky(), b',', true).unwrap());
741    }
742
743    /// Every format copies a float as stored, not as Polars' compact display
744    /// (`1.0000e6`) rounds it for the screen.
745    #[test]
746    fn every_format_copies_floats_exactly() {
747        let df = df!("x" => [1000000.125f64, -0.0]).unwrap();
748        for format in [CopyFormat::Tsv, CopyFormat::Csv, CopyFormat::Markdown] {
749            let payload = tabular_payload(&df, format, false, true).unwrap();
750            assert!(
751                payload.text.contains("1000000.125"),
752                "{format:?}: {payload:?}"
753            );
754            assert!(!payload.text.contains("e6"), "{format:?}: {payload:?}");
755            if let Some(html) = payload.html {
756                assert!(html.contains("<td>1000000.125</td>"), "{html}");
757            }
758        }
759    }
760
761    /// Every kind of cell a copy meets: quoting, nulls, wide characters, numbers
762    /// either side of zero, and a list written as JSON.
763    fn varied() -> DataFrame {
764        let mut df = df!(
765            "name" => ["plain", "tab\there", "pipe|and\nnewline", "\"quoted\"", "été", "日本語"],
766            "n" => [Some(1i64), Some(-22), None, Some(4), Some(1_000_000), Some(0)],
767            "x" => [Some(0.5f64), None, Some(-1.25), Some(3.0), Some(1e-9), Some(2.5)],
768        )
769        .unwrap();
770        let tags: ListChunked = (0..6)
771            .map(|i| (i % 2 == 0).then(|| Series::new("".into(), [format!("t{i}"), "a,b".into()])))
772            .collect();
773        df.with_column(tags.with_name("tags".into()).into_column())
774            .unwrap();
775        df
776    }
777
778    #[test]
779    fn markdown_writes_what_it_wrote_holding_every_cell() {
780        let empty = varied().head(Some(0));
781        for df in [tricky(), varied(), empty] {
782            let df = crate::nested_json::frame_as_json(&df).unwrap();
783            let text = markdown(&df).unwrap();
784            assert_eq!(text, markdown_reference(&df).unwrap());
785            let layout = {
786                let mut layout = MarkdownLayout::new(&df);
787                layout.measure(&df).unwrap();
788                layout
789            };
790            assert_eq!(layout.len(df.height()), text.chars().count());
791        }
792    }
793
794    #[test]
795    fn base64_is_sized_before_it_is_encoded() {
796        use base64::Engine as _;
797        for (bytes, encoded) in [
798            (0, 0),
799            (1, 4),
800            (2, 4),
801            (3, 4),
802            (4, 8),
803            (5, 8),
804            (6, 8),
805            (7, 12),
806        ] {
807            assert_eq!(base64_len(bytes), encoded, "{bytes}");
808            let text = "a".repeat(bytes);
809            let real = base64::engine::general_purpose::STANDARD
810                .encode(&text)
811                .len();
812            assert_eq!(real, encoded);
813        }
814        // UTF-8 is sized by its bytes: "é" is two.
815        assert!(osc52_sequence("ééé", 8).is_ok());
816        assert!(osc52_sequence("éééé", 8).is_err());
817        // Exactly at the cap goes; a byte more does not.
818        assert_eq!(
819            osc52_sequence("abcdef", 8).unwrap(),
820            "\x1b]52;c;YWJjZGVm\x07"
821        );
822        let err = osc52_sequence("abcdefg", 8).unwrap_err();
823        assert!(err.starts_with("the copy is 1 KB of base64"), "{err}");
824    }
825
826    #[test]
827    fn a_bounded_table_copy_writes_what_a_whole_one_would() {
828        for format in CopyFormat::ALL {
829            for header in [true, false] {
830                for df in [tricky(), varied(), varied().head(Some(0))] {
831                    let whole = tabular_payload(&df, format, header, false).unwrap().text;
832                    let (text, rows) =
833                        bounded_table_text(df.clone().lazy(), format, header, 1 << 20).unwrap();
834                    assert_eq!(text, whole, "{format:?} header {header}");
835                    assert_eq!(rows, df.height());
836                }
837            }
838        }
839    }
840
841    /// Every copy format, whole and bounded, takes durations as the ISO 8601 a
842    /// CSV export writes: TSV, CSV, Markdown and the HTML flavor.
843    #[test]
844    fn durations_copy_as_a_csv_export_writes_them() {
845        use crate::nested_json::tests::{duration_text, durations};
846        let df = durations();
847        let row = |i: usize, separator: &str| {
848            duration_text()
849                .iter()
850                .map(|(_, text)| text[i].unwrap_or(""))
851                .collect::<Vec<_>>()
852                .join(separator)
853        };
854        for format in CopyFormat::ALL {
855            let payload = tabular_payload(&df, format, true, true).unwrap();
856            let (bounded, rows) =
857                bounded_table_text(df.clone().lazy(), format, true, 1 << 20).unwrap();
858            assert_eq!(bounded, payload.text, "{format:?}");
859            assert_eq!(rows, df.height());
860            let lines: Vec<&str> = payload.text.lines().collect();
861            match format {
862                CopyFormat::Tsv | CopyFormat::Csv => {
863                    let separator = if format == CopyFormat::Tsv { "\t" } else { "," };
864                    assert_eq!(lines[0], ["ms", "us", "ns"].join(separator));
865                    for (i, line) in lines[1..].iter().enumerate() {
866                        assert_eq!(*line, row(i, separator), "{format:?} row {i}");
867                    }
868                    let html = payload.html.expect("html beside tsv and csv");
869                    assert!(html.contains("<td>-PT1.5S</td>"), "{html}");
870                    assert!(html.contains("<tr><td></td><td></td><td></td></tr>"));
871                }
872                CopyFormat::Markdown => {
873                    assert_eq!(lines.len(), 2 + df.height());
874                    for (i, line) in lines[2..].iter().enumerate() {
875                        let cells: Vec<&str> =
876                            line.trim_matches('|').split('|').map(str::trim).collect();
877                        assert_eq!(cells.join(","), row(i, ","), "row {i}");
878                    }
879                }
880            }
881        }
882    }
883
884    /// Dates and datetimes copy as they always did: TSV and CSV as the CSV writer
885    /// writes them, Markdown and the HTML flavor as exact text. One past the
886    /// calendar, on which the writer panics, is its stored number in each.
887    #[test]
888    fn dates_copy_as_each_format_wrote_them() {
889        use crate::nested_json::tests::calendar;
890        let df = calendar(false);
891        for format in CopyFormat::ALL {
892            let payload = tabular_payload(&df, format, true, true).unwrap();
893            let (bounded, _) =
894                bounded_table_text(df.clone().lazy(), format, true, 1 << 20).unwrap();
895            assert_eq!(bounded, payload.text, "{format:?}");
896            let separator = match format {
897                CopyFormat::Tsv => b'\t',
898                CopyFormat::Csv => b',',
899                CopyFormat::Markdown => {
900                    assert_eq!(payload.text, markdown_reference(&df).unwrap());
901                    continue;
902                }
903            };
904            let mut written = Vec::new();
905            CsvWriter::new(&mut written)
906                .with_separator(separator)
907                .finish(&mut df.clone())
908                .unwrap();
909            let written = String::from_utf8(written).unwrap();
910            assert_eq!(payload.text, written.trim_end_matches('\n'), "{format:?}");
911            assert_eq!(payload.html, Some(html_table(&df, true).unwrap()));
912        }
913        assert!(
914            markdown_reference(&df)
915                .unwrap()
916                .contains("| 1970-01-01 01:00:00.000000 +01:00 |")
917        );
918
919        let past = calendar(true);
920        let stored = "-9223372036854775807 us since 1970-01-01 UTC";
921        for format in CopyFormat::ALL {
922            let payload = tabular_payload(&past, format, true, true).unwrap();
923            let (bounded, _) =
924                bounded_table_text(past.clone().lazy(), format, true, 1 << 20).unwrap();
925            assert_eq!(bounded, payload.text, "{format:?}");
926            assert!(payload.text.contains(stored), "{format:?}");
927            if let Some(html) = payload.html {
928                assert!(html.contains(&format!("<td>{stored}</td>")), "{html}");
929            }
930        }
931    }
932
933    /// A failed read says what the collected copy would: Polars' words, tidied.
934    #[test]
935    fn a_bounded_table_copy_fails_in_the_words_a_whole_one_does() {
936        let lf = df!("s" => ["a"]).unwrap().lazy().select([col("missing")]);
937        let whole = crate::statistics::collect_lazy(lf.clone(), true).unwrap_err();
938        let err = bounded_table_text(lf, CopyFormat::Tsv, true, 1 << 20).unwrap_err();
939        assert_eq!(
940            err,
941            crate::error_display::user_message_from_polars(&whole),
942            "{whole}"
943        );
944    }
945
946    #[test]
947    fn a_bounded_table_copy_stops_at_the_first_batch_over_the_cap() {
948        // 100,000 rows of 16 bytes: 1.6 MB of TSV, against a 64 KB cap.
949        let limit = 64 * 1024;
950        let rows = 100_000;
951        let df = df!("id" => (0..rows as i64).map(|i| i + 1_000_000_000).collect::<Vec<_>>(),
952                     "k" => (0..rows as i64).map(|i| i % 10 + 10).collect::<Vec<_>>())
953        .unwrap();
954        for format in CopyFormat::ALL {
955            let mut text = BoundedText::new(format, true, limit);
956            let mut taken = 0;
957            for offset in (0..rows).step_by(BOUNDED_BATCH_ROWS) {
958                taken += 1;
959                if text.take(df.slice(offset as i64, BOUNDED_BATCH_ROWS)) {
960                    break;
961                }
962            }
963            // About 48 KB of text fits: the stop comes within a batch of it.
964            let fits = limit / 4 * 3 / 16;
965            assert!(
966                taken * BOUNDED_BATCH_ROWS <= fits + 2 * BOUNDED_BATCH_ROWS,
967                "{format:?}: {taken} batches"
968            );
969            assert!(text.text.len() <= limit / 4 * 3 + BOUNDED_BATCH_ROWS * 32);
970            let err = text.finish().unwrap_err();
971            assert!(err.starts_with("the copy is over 64 KB of base64"), "{err}");
972
973            let err = bounded_table_text(df.clone().lazy(), format, true, limit).unwrap_err();
974            assert!(err.contains("osc52_limit"), "{err}");
975        }
976    }
977
978    #[test]
979    fn a_capped_destination_takes_text_only() {
980        let osc = destination(BackendChoice::Osc52, 4096).unwrap();
981        assert_eq!(
982            osc.accepts(),
983            Accepts {
984                html: false,
985                base64_limit: Some(4096)
986            }
987        );
988        // Auto is whichever came up: the terminal path where there is no display.
989        let auto = destination(BackendChoice::Auto, 4096).unwrap();
990        assert_eq!(auto.accepts().html, auto.describe() == "clipboard");
991        assert_eq!(
992            auto.accepts().base64_limit.is_some(),
993            auto.describe() == "terminal"
994        );
995    }
996
997    #[test]
998    fn html_escapes_markup_in_values() {
999        let df = df!("x" => ["<b>&"]).unwrap();
1000        let html = html_table(&df, false).unwrap();
1001        assert!(html.contains("<td>&lt;b&gt;&amp;</td>"), "{html}");
1002    }
1003
1004    #[test]
1005    fn osc52_wraps_base64_and_the_cap_names_the_config() {
1006        let seq = osc52_sequence("hello", 1024).unwrap();
1007        assert_eq!(seq, "\x1b]52;c;aGVsbG8=\x07");
1008        let err = osc52_sequence("hello world, far too long", 8).unwrap_err();
1009        assert!(err.contains("osc52_limit"), "{err}");
1010    }
1011
1012    #[test]
1013    fn backend_choice_parses_the_config_words() {
1014        assert_eq!(BackendChoice::parse("auto"), Some(BackendChoice::Auto));
1015        assert_eq!(BackendChoice::parse("Native"), Some(BackendChoice::Native));
1016        assert_eq!(BackendChoice::parse("OSC52"), Some(BackendChoice::Osc52));
1017        assert_eq!(BackendChoice::parse("wayland"), None);
1018    }
1019}