1use polars::prelude::*;
24use std::io::Write as _;
25
26#[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#[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
61pub trait Destination {
64 fn write(&mut self, payload: Payload) -> Result<(), String>;
66 fn describe(&self) -> &'static str;
68 fn accepts(&self) -> Accepts {
69 Accepts {
70 html: true,
71 base64_limit: None,
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct Accepts {
79 pub html: bool,
81 pub base64_limit: Option<usize>,
83}
84
85pub 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
114pub struct Osc52 {
116 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 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
143pub fn base64_len(bytes: usize) -> usize {
145 bytes.div_ceil(3).saturating_mul(4)
146}
147
148pub 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
165pub(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
183pub 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 Err(_) => Box::new(Osc52 { limit: osc52_limit }),
196 }),
197 }
198}
199
200#[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
223pub 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 while text.ends_with('\n') || text.ends_with('\r') {
239 text.pop();
240 }
241 Ok(text)
242}
243
244pub 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
256struct 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 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 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 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 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 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
370fn 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 v => markdown_escape(&crate::exact::value_text(&v)),
377 })
378}
379
380pub fn html_table(df: &DataFrame, header: bool) -> Result<String, String> {
383 let escape = |s: &str| {
384 s.replace('&', "&")
385 .replace('<', "<")
386 .replace('>', ">")
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
419pub 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
443const BOUNDED_BATCH_ROWS: usize = 1024;
446
447pub 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 Ok(text.take(batch))
474 }),
475 true,
476 std::num::NonZeroUsize::new(BOUNDED_BATCH_ROWS),
477 )
478 .map_err(polars_error)?;
479 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 text.take(DataFrame::empty_with_schema(&schema));
489 }
490 text.finish()
491}
492
493struct BoundedText {
495 format: CopyFormat,
496 header: bool,
497 limit: usize,
498 started: bool,
499 text: String,
500 rows: usize,
501 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 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 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 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 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 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 assert_eq!(lines[2], "\"tab\there\"\t2");
700 assert!(text.contains("\"pipe|and\nnewline\"\t"));
701 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 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 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 #[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 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 assert!(osc52_sequence("ééé", 8).is_ok());
816 assert!(osc52_sequence("éééé", 8).is_err());
817 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 #[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 #[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 #[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 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 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 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><b>&</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}