1use std::fs::File;
18use std::io::Read;
19use std::path::Path;
20
21use relm4::gtk::gio;
22
23use crate::highlight::{self, Language, Span};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PreviewKind {
28 Image,
30 Gif,
32 Video,
34 Audio,
36 Text,
38 Csv,
40 Json,
42 Toml,
44 Yaml,
46 Archive,
48}
49
50impl PreviewKind {
51 pub fn is_media(self) -> bool {
54 matches!(self, Self::Image | Self::Gif | Self::Video | Self::Audio)
55 }
56
57 pub fn menu_label(self) -> Option<&'static str> {
60 match self {
61 Self::Audio => Some("Show Player"),
62 Self::Video => Some("Show Video"),
63 Self::Text | Self::Csv | Self::Json | Self::Toml | Self::Yaml => Some("Show Preview"),
64 Self::Archive => Some("Show Contents"),
65 Self::Image | Self::Gif => None,
66 }
67 }
68}
69
70const SNIFF_BYTES: u64 = 8192;
72const PREVIEW_BYTES: u64 = 64 * 1024;
74const PREVIEW_LINES: usize = 300;
78const TABLE_ROWS: usize = 8;
80const TABLE_COLS: usize = 6;
82const MAX_CELL_CHARS: usize = 32;
84const ARCHIVE_ENTRIES: usize = 12;
86
87pub fn detect(path: &Path) -> Option<PreviewKind> {
90 if !path.is_file() {
91 return None;
92 }
93 match raw_content_type(path) {
94 Some(ctype) => kind_for_content_type(&ctype).or_else(|| extension_kind(path)),
95 None => extension_kind(path),
96 }
97}
98
99fn raw_content_type(path: &Path) -> Option<String> {
110 let data = read_prefix(path, SNIFF_BYTES).unwrap_or_default();
111 let (data_only, _) = gio::content_type_guess(None::<&Path>, Some(&data[..]));
112 let (both, _) = gio::content_type_guess(Some(path), Some(&data[..]));
113 let data_only = data_only.to_string();
114 let both = both.to_string();
115
116 if is_generic(&data_only) {
117 return (!is_generic(&both)).then_some(both);
118 }
119 if is_textual(&data_only) {
120 return Some(if is_textual(&both) { both } else { data_only });
123 }
124 Some(data_only)
125}
126
127fn is_generic(ctype: &str) -> bool {
129 ctype.is_empty() || ctype == "application/octet-stream" || ctype == "application/x-zerosize"
130}
131
132fn kind_for_content_type(ctype: &str) -> Option<PreviewKind> {
134 if ctype == "image/svg+xml" {
136 return Some(PreviewKind::Text);
137 }
138 if ctype == "image/gif" {
139 return Some(PreviewKind::Gif);
140 }
141 if gio::functions::content_type_is_a(ctype, "image/*") {
142 return Some(PreviewKind::Image);
143 }
144 if gio::functions::content_type_is_a(ctype, "video/*") {
145 return Some(PreviewKind::Video);
146 }
147 if gio::functions::content_type_is_a(ctype, "audio/*") {
148 return cfg!(feature = "audio").then_some(PreviewKind::Audio);
149 }
150 if cfg!(feature = "audio")
152 && matches!(ctype, "application/ogg" | "application/x-ogg")
153 {
154 return Some(PreviewKind::Audio);
155 }
156 if is_archive(ctype) {
157 return Some(PreviewKind::Archive);
158 }
159 if is_csv(ctype) {
160 return Some(PreviewKind::Csv);
161 }
162 if let Some(kind) = structured_kind(ctype) {
163 return Some(kind);
164 }
165 if is_text(ctype) {
166 return Some(PreviewKind::Text);
167 }
168 None
169}
170
171fn is_csv(ctype: &str) -> bool {
173 matches!(
174 ctype,
175 "text/csv" | "text/tab-separated-values" | "application/csv" | "text/x-csv"
176 | "text/x-comma-separated-values"
177 )
178}
179
180fn structured_kind(ctype: &str) -> Option<PreviewKind> {
182 match ctype {
183 "application/json" | "text/json" | "application/x-json" | "application/ld+json"
184 | "application/x-ndjson" => Some(PreviewKind::Json),
185 "application/toml" | "text/toml" | "text/x-toml" | "application/x-toml" => {
186 Some(PreviewKind::Toml)
187 }
188 "application/yaml" | "text/yaml" | "text/x-yaml" | "application/x-yaml"
189 | "application/x-yml" | "text/x-yml" => Some(PreviewKind::Yaml),
190 _ => None,
191 }
192}
193
194fn is_textual(ctype: &str) -> bool {
196 is_text(ctype) || is_csv(ctype) || structured_kind(ctype).is_some()
197}
198
199fn is_text(ctype: &str) -> bool {
202 gio::functions::content_type_is_a(ctype, "text/*")
203 || matches!(
204 ctype,
205 "application/xml"
206 | "application/javascript"
207 | "application/x-javascript"
208 | "application/ecmascript"
209 | "application/x-shellscript"
210 | "application/x-perl"
211 | "application/x-python"
212 | "application/x-ruby"
213 | "application/x-tcl"
214 | "application/x-awk"
215 | "application/x-php"
216 | "application/x-httpd-php"
217 | "application/x-lua"
218 | "application/x-haskell"
219 | "application/x-tex"
220 | "application/x-texinfo"
221 | "application/x-desktop"
222 | "application/x-wine-extension-ini"
223 )
224}
225
226fn is_archive(ctype: &str) -> bool {
229 matches!(
230 ctype,
231 "application/zip"
232 | "application/x-tar"
233 | "application/x-gtar"
234 | "application/gzip"
235 | "application/x-gzip"
236 | "application/x-compressed-tar"
237 | "application/x-bzip2"
238 | "application/x-bzip"
239 | "application/x-bzip-compressed-tar"
240 | "application/x-xz"
241 | "application/x-xz-compressed-tar"
242 | "application/x-lzma"
243 | "application/x-lz4"
244 | "application/x-lzip"
245 | "application/zstd"
246 | "application/x-zstd"
247 | "application/x-7z-compressed"
248 | "application/vnd.rar"
249 | "application/x-rar"
250 | "application/x-rar-compressed"
251 | "application/x-cpio"
252 | "application/x-archive"
253 | "application/x-arj"
254 | "application/x-iso9660-image"
255 | "application/x-cd-image"
256 | "application/x-apple-diskimage"
257 | "application/vnd.debian.binary-package"
258 | "application/x-deb"
259 | "application/x-rpm"
260 | "application/java-archive"
261 | "application/epub+zip"
262 | "application/vnd.android.package-archive"
263 | "application/x-compress"
264 )
265}
266
267fn extension_kind(path: &Path) -> Option<PreviewKind> {
271 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
272 if cfg!(feature = "audio")
273 && matches!(
274 ext.as_str(),
275 "mp3" | "m4a" | "m4b" | "aac" | "flac" | "wav" | "opus" | "oga" | "wma" | "aif"
276 | "aiff" | "alac"
277 )
278 {
279 return Some(PreviewKind::Audio);
280 }
281 Some(match ext.as_str() {
282 "gif" => PreviewKind::Gif,
283 "jpg" | "jpeg" | "png" | "webp" | "bmp" | "tif" | "tiff" | "avif" | "heic" | "heif" => {
284 PreviewKind::Image
285 }
286 "mp4" | "m4v" | "mkv" | "webm" | "mov" | "avi" | "wmv" | "flv" | "ogv" | "ogg" | "mpg"
287 | "mpeg" | "3gp" | "m2ts" => PreviewKind::Video,
288 "zip" | "tar" | "gz" | "tgz" | "bz2" | "xz" | "7z" | "rar" | "zst" | "lz4" | "lz"
289 | "iso" | "deb" | "rpm" | "jar" | "epub" | "apk" | "cpio" | "ar" => PreviewKind::Archive,
290 "csv" | "tsv" => PreviewKind::Csv,
291 "json" => PreviewKind::Json,
292 "toml" => PreviewKind::Toml,
293 "yaml" | "yml" => PreviewKind::Yaml,
294 "txt" | "md" | "markdown" | "log" | "rs" | "go" | "c" | "h" | "cpp" | "hpp" | "py" | "js"
295 | "ts" | "sh" | "ini" | "conf" | "cfg" | "xml" | "html" | "css" | "svg" => {
296 PreviewKind::Text
297 }
298 _ => return None,
299 })
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
309pub enum DocumentData {
310 Lines(DocumentLines),
312 Table(TableData),
314 Structured {
316 lines: DocumentLines,
317 status: ParseStatus,
318 },
319 Archive(ArchiveData),
321}
322
323pub fn load_document(path: &Path, kind: PreviewKind) -> Option<DocumentData> {
326 let language = highlight::language_for(path);
327 match kind {
328 PreviewKind::Text => {
329 let text = read_text(path, PREVIEW_BYTES)?;
330 Some(DocumentData::Lines(lines_from(&text, PREVIEW_LINES, language)))
331 }
332 PreviewKind::Json | PreviewKind::Toml | PreviewKind::Yaml => {
333 let text = read_text(path, PREVIEW_BYTES)?;
334 Some(DocumentData::Structured {
335 lines: lines_from(&text, PREVIEW_LINES, language),
336 status: parse_status(&text, kind),
337 })
338 }
339 PreviewKind::Csv => read_table(path, TABLE_ROWS, TABLE_COLS).map(DocumentData::Table),
340 PreviewKind::Archive => Some(DocumentData::Archive(list_archive(path))),
341 PreviewKind::Image | PreviewKind::Gif | PreviewKind::Video | PreviewKind::Audio => None,
342 }
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct DocumentLines {
350 pub lines: Vec<String>,
351 pub more: bool,
352 pub syntax: Vec<Span>,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct TableData {
359 pub rows: Vec<Vec<String>>,
360 pub more: bool,
361}
362
363#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct ParseStatus {
366 pub ok: bool,
367 pub detail: String,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq)]
372pub enum ArchiveData {
373 Listed(ArchiveListing),
374 Unsupported(String),
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct ArchiveListing {
380 pub entries: Vec<ArchiveEntry>,
381 pub more: bool,
382}
383
384#[derive(Debug, Clone, PartialEq, Eq)]
386pub struct ArchiveEntry {
387 pub name: String,
388 pub size: u64,
389 pub is_dir: bool,
390}
391
392fn read_prefix(path: &Path, max: u64) -> Option<Vec<u8>> {
393 let file = File::open(path).ok()?;
394 let mut buf = Vec::new();
395 file.take(max).read_to_end(&mut buf).ok()?;
396 Some(buf)
397}
398
399fn read_text(path: &Path, max: u64) -> Option<String> {
400 let bytes = read_prefix(path, max)?;
401 Some(decode_text(&bytes))
402}
403
404fn decode_text(bytes: &[u8]) -> String {
409 let decoded = if bytes.starts_with(&[0xFF, 0xFE]) {
410 utf16_to_string(&bytes[2..], true)
411 } else if bytes.starts_with(&[0xFE, 0xFF]) {
412 utf16_to_string(&bytes[2..], false)
413 } else {
414 String::from_utf8_lossy(bytes).into_owned()
415 };
416 decoded.replace('\0', "")
417}
418
419fn utf16_to_string(bytes: &[u8], little_endian: bool) -> String {
421 let units: Vec<u16> = bytes
422 .as_chunks::<2>()
423 .0
424 .iter()
425 .map(|pair| {
426 if little_endian {
427 u16::from_le_bytes(*pair)
428 } else {
429 u16::from_be_bytes(*pair)
430 }
431 })
432 .collect();
433 String::from_utf16_lossy(&units)
434}
435
436fn lines_from(text: &str, max_lines: usize, language: Language) -> DocumentLines {
439 let mut all = text.lines();
440 let lines: Vec<String> = all.by_ref().take(max_lines).map(truncate_line).collect();
441 let more = all.next().is_some();
443 let syntax = highlight::highlight(language, &lines.join("\n"));
444 DocumentLines { lines, more, syntax }
445}
446
447fn truncate_line(line: &str) -> String {
449 const MAX_LINE_CHARS: usize = 400;
450 if line.chars().count() <= MAX_LINE_CHARS {
451 return line.to_owned();
452 }
453 let mut out: String = line.chars().take(MAX_LINE_CHARS).collect();
454 out.push('…');
455 out
456}
457
458fn parse_status(text: &str, kind: PreviewKind) -> ParseStatus {
461 match kind {
462 PreviewKind::Json => {
463 status_of(serde_json::from_str::<serde_json::Value>(text).err(), "valid JSON")
464 }
465 PreviewKind::Toml => status_of(toml::from_str::<toml::Value>(text).err(), "valid TOML"),
466 PreviewKind::Yaml => {
467 status_of(serde_norway::from_str::<serde_norway::Value>(text).err(), "valid YAML")
468 }
469 _ => ParseStatus { ok: true, detail: String::new() },
470 }
471}
472
473fn status_of(error: Option<impl std::fmt::Display>, valid: &str) -> ParseStatus {
475 match error {
476 None => ParseStatus { ok: true, detail: valid.to_owned() },
477 Some(err) => ParseStatus {
478 ok: false,
479 detail: first_line(&err.to_string()).to_owned(),
480 },
481 }
482}
483
484fn first_line(message: &str) -> &str {
486 message.lines().next().unwrap_or(message).trim()
487}
488
489fn read_table(path: &Path, max_rows: usize, max_cols: usize) -> Option<TableData> {
490 let text = read_text(path, PREVIEW_BYTES)?;
491 let delimiter = first_line_of(&text)
494 .map(|line| {
495 if line.matches('\t').count() > line.matches(',').count() {
496 '\t'
497 } else {
498 ','
499 }
500 })
501 .unwrap_or(',');
502
503 let mut lines = text.lines();
504 let rows: Vec<Vec<String>> = lines
505 .by_ref()
506 .take(max_rows)
507 .map(|line| {
508 split_fields(line, delimiter)
509 .into_iter()
510 .take(max_cols)
511 .map(|cell| truncate_cell(&cell))
512 .collect()
513 })
514 .collect();
515 if rows.is_empty() {
516 return None;
517 }
518 let more = lines.next().is_some();
519 Some(TableData { rows, more })
520}
521
522fn first_line_of(text: &str) -> Option<&str> {
523 text.lines().find(|line| !line.trim().is_empty())
524}
525
526fn split_fields(line: &str, delimiter: char) -> Vec<String> {
529 let mut fields = Vec::new();
530 let mut field = String::new();
531 let mut quoted = false;
532 let mut chars = line.chars().peekable();
533 while let Some(c) = chars.next() {
534 match c {
535 '"' if quoted && chars.peek() == Some(&'"') => {
536 field.push('"');
537 chars.next();
538 }
539 '"' => quoted = !quoted,
540 c if c == delimiter && !quoted => fields.push(std::mem::take(&mut field)),
541 c => field.push(c),
542 }
543 }
544 fields.push(field);
545 fields
546}
547
548fn truncate_cell(cell: &str) -> String {
549 let cell = cell.trim();
550 if cell.chars().count() <= MAX_CELL_CHARS {
551 return cell.to_owned();
552 }
553 let mut out: String = cell.chars().take(MAX_CELL_CHARS).collect();
554 out.push('…');
555 out
556}
557
558enum ArchiveFlavor {
564 Zip,
565 GzipTar,
567 Tar,
568 Other(&'static str),
570}
571
572fn list_archive(path: &Path) -> ArchiveData {
573 match archive_flavor(path) {
574 ArchiveFlavor::Zip => list_zip(path),
575 ArchiveFlavor::GzipTar => list_tar(path, true),
576 ArchiveFlavor::Tar => list_tar(path, false),
577 ArchiveFlavor::Other(name) => {
578 ArchiveData::Unsupported(format!("{name} archive, contents not listed"))
579 }
580 }
581}
582
583fn archive_flavor(path: &Path) -> ArchiveFlavor {
584 if let Some(ctype) = raw_content_type(path) {
585 match ctype.as_str() {
586 "application/zip" => return ArchiveFlavor::Zip,
587 "application/gzip" | "application/x-gzip" | "application/x-compressed-tar" => {
588 return ArchiveFlavor::GzipTar;
589 }
590 "application/x-tar" | "application/x-gtar" => return ArchiveFlavor::Tar,
591 _ => {}
592 }
593 }
594 match path
595 .extension()
596 .and_then(|ext| ext.to_str())
597 .map(|ext| ext.to_ascii_lowercase())
598 .as_deref()
599 {
600 Some("zip" | "jar" | "apk" | "epub") => ArchiveFlavor::Zip,
601 Some("gz" | "tgz") => ArchiveFlavor::GzipTar,
602 Some("tar") => ArchiveFlavor::Tar,
603 Some("bz2") => ArchiveFlavor::Other("bzip2"),
604 Some("xz") => ArchiveFlavor::Other("xz"),
605 Some("zst") => ArchiveFlavor::Other("zstandard"),
606 Some("7z") => ArchiveFlavor::Other("7z"),
607 Some("rar") => ArchiveFlavor::Other("rar"),
608 Some("iso") => ArchiveFlavor::Other("ISO image"),
609 _ => ArchiveFlavor::Other("unknown"),
610 }
611}
612
613fn list_zip(path: &Path) -> ArchiveData {
614 let Ok(file) = File::open(path) else {
615 return ArchiveData::Unsupported("zip, unreadable".to_owned());
616 };
617 let Ok(mut archive) = zip::ZipArchive::new(std::io::BufReader::new(file)) else {
618 return ArchiveData::Unsupported("zip, unreadable".to_owned());
619 };
620 let total = archive.len();
621 let mut entries = Vec::new();
622 for index in 0..total.min(ARCHIVE_ENTRIES) {
623 let Ok(entry) = archive.by_index(index) else {
624 continue;
625 };
626 entries.push(ArchiveEntry {
627 name: entry.name().to_owned(),
628 size: entry.size(),
629 is_dir: entry.is_dir(),
630 });
631 }
632 ArchiveData::Listed(ArchiveListing {
633 more: total > entries.len(),
634 entries,
635 })
636}
637
638fn list_tar(path: &Path, compressed: bool) -> ArchiveData {
639 let Ok(file) = File::open(path) else {
640 return ArchiveData::Unsupported("tar, unreadable".to_owned());
641 };
642 let reader: Box<dyn Read> = if compressed {
643 Box::new(flate2::read::GzDecoder::new(file))
644 } else {
645 Box::new(file)
646 };
647 let mut archive = tar::Archive::new(reader);
648 let Ok(mut iter) = archive.entries() else {
649 return ArchiveData::Unsupported("tar, unreadable".to_owned());
650 };
651
652 let mut entries = Vec::new();
653 let mut more = false;
654 while entries.len() < ARCHIVE_ENTRIES {
655 match iter.next() {
656 Some(Ok(entry)) => {
657 let name = entry
658 .path()
659 .map(|p| p.to_string_lossy().into_owned())
660 .unwrap_or_default();
661 let header = entry.header();
662 entries.push(ArchiveEntry {
663 name,
664 size: header.size().unwrap_or(0),
665 is_dir: header.entry_type().is_dir(),
666 });
667 }
668 Some(Err(_)) => break,
669 None => break,
670 }
671 }
672 if entries.len() == ARCHIVE_ENTRIES {
673 more = matches!(iter.next(), Some(Ok(_)));
675 }
676 if entries.is_empty() {
677 let label = if compressed { "gzip" } else { "tar" };
678 return ArchiveData::Unsupported(format!("{label}, not a tar archive"));
679 }
680 ArchiveData::Listed(ArchiveListing { entries, more })
681}
682
683#[cfg(test)]
684mod tests {
685 use std::io::Write;
686
687 use tempfile::tempdir;
688
689 use super::*;
690
691 fn write(dir: &Path, name: &str, contents: &[u8]) -> std::path::PathBuf {
692 let path = dir.join(name);
693 std::fs::write(&path, contents).unwrap();
694 path
695 }
696
697 #[test]
698 fn detection_prefers_content_over_extension() {
699 let dir = tempdir().unwrap();
700 let png: &[u8] = &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, b'I', b'H', b'D', b'R'];
701 let fake = write(dir.path(), "fake.png", b"hello, this is plain text\n");
703 assert_eq!(detect(&fake), Some(PreviewKind::Text));
704 let named_text = write(dir.path(), "actually-an-image.txt", png);
706 assert_eq!(detect(&named_text), Some(PreviewKind::Image));
707 let extensionless = write(dir.path(), "picture", png);
709 assert_eq!(detect(&extensionless), Some(PreviewKind::Image));
710 let text_zip = write(dir.path(), "notes.zip", b"just some notes\n");
712 assert_eq!(detect(&text_zip), Some(PreviewKind::Text));
713 }
714
715 #[test]
716 fn detection_classifies_documents_and_archives() {
717 let dir = tempdir().unwrap();
718 let cases: &[(&str, &[u8], PreviewKind)] = &[
719 ("a.json", br#"{"a": 1}"#, PreviewKind::Json),
720 ("a.toml", b"a = 1\n", PreviewKind::Toml),
721 ("a.yaml", b"a: 1\n", PreviewKind::Yaml),
722 ("a.csv", b"a,b\n1,2\n", PreviewKind::Csv),
723 ("a.tsv", b"a\tb\n1\t2\n", PreviewKind::Csv),
724 ("a.txt", b"plain text\n", PreviewKind::Text),
725 ("a.rs", b"fn main() {}\n", PreviewKind::Text),
726 ("a.ts", b"const x: number = 1;\n", PreviewKind::Text),
727 ("a.csv", b"a,b\n1,2\n", PreviewKind::Csv),
728 ];
729 for (name, contents, expected) in cases {
730 let path = write(dir.path(), name, contents);
731 assert_eq!(detect(&path), Some(*expected), "{name}");
732 }
733 assert_eq!(detect(dir.path()), None, "directories are not previews");
734 assert_eq!(detect(Path::new("")), None);
735 }
736
737 #[test]
738 fn detection_can_be_forced_by_extension_when_content_is_generic() {
739 let dir = tempdir().unwrap();
741 let path = write(dir.path(), "empty.json", b"");
742 assert_eq!(detect(&path), Some(PreviewKind::Json));
743 }
744
745 #[test]
746 fn lines_are_capped_and_note_more() {
747 let text = "one\ntwo\nthree\nfour\n";
748 let lines = lines_from(text, 2, Language::Plain);
749 assert_eq!(lines.lines, vec!["one", "two"]);
750 assert!(lines.more);
751
752 let lines = lines_from(text, 99, Language::Plain);
753 assert_eq!(lines.lines.len(), 4);
754 assert!(!lines.more);
755 assert!(lines.syntax.is_empty());
756 }
757
758 #[test]
759 fn text_preview_carries_syntax_spans() {
760 let dir = tempfile::tempdir().unwrap();
761 let path = dir.path().join("demo.rs");
762 std::fs::write(&path, "fn main() { let x = 1; // c\n}\n").unwrap();
763 let Some(DocumentData::Lines(lines)) = load_document(&path, PreviewKind::Text) else {
764 panic!("expected text lines");
765 };
766 assert!(!lines.syntax.is_empty());
767 assert_eq!(lines.syntax[0].start, 0);
769 assert_eq!(lines.syntax[0].class, crate::highlight::TokenClass::Keyword);
770 let plain = dir.path().join("note.txt");
772 std::fs::write(&plain, "just text\n").unwrap();
773 let Some(DocumentData::Lines(lines)) = load_document(&plain, PreviewKind::Text) else {
774 panic!("expected text lines");
775 };
776 assert!(lines.syntax.is_empty());
777 }
778
779 #[test]
780 fn decode_text_honours_utf16_bom_and_drops_nuls() {
781 assert_eq!(decode_text(b"plain text\n"), "plain text\n");
783
784 let mut le = vec![0xFF, 0xFE];
786 for unit in "[.ShellClassInfo]\r\n".encode_utf16() {
787 le.extend_from_slice(&unit.to_le_bytes());
788 }
789 assert_eq!(decode_text(&le), "[.ShellClassInfo]\r\n");
790
791 let mut be = vec![0xFE, 0xFF];
793 for unit in "hé".encode_utf16() {
794 be.extend_from_slice(&unit.to_be_bytes());
795 }
796 assert_eq!(decode_text(&be), "hé");
797
798 assert_eq!(decode_text(b"a\0b"), "ab");
800 }
801
802 #[test]
803 fn long_lines_are_truncated() {
804 let long = "x".repeat(1000);
805 let line = truncate_line(&long);
806 assert!(line.chars().count() < 1000);
807 assert!(line.ends_with('…'));
808 }
809
810 #[test]
811 fn csv_fields_honour_quotes() {
812 let fields = split_fields(r#"a,"b,c",d"#, ',');
813 assert_eq!(fields, vec!["a", "b,c", "d"]);
814 let fields = split_fields(r#""say ""hi""",x"#, ',');
815 assert_eq!(fields, vec![r#"say "hi""#, "x"]);
816 }
817
818 #[test]
819 fn table_sniffs_the_delimiter_and_limits_cells() {
820 let dir = tempdir().unwrap();
821 let path = write(dir.path(), "grid.csv", b"a,b,c\n1,2,3\n");
822 let Some(DocumentData::Table(table)) = load_document(&path, PreviewKind::Csv) else {
823 panic!("expected a table");
824 };
825 assert_eq!(table.rows[0], vec!["a", "b", "c"]);
826 assert_eq!(table.rows[1], vec!["1", "2", "3"]);
827
828 let path = write(dir.path(), "grid.csv", b"a\tb\tc\n1\t2\t3\n");
830 let Some(DocumentData::Table(table)) = load_document(&path, PreviewKind::Csv) else {
831 panic!("expected a table");
832 };
833 assert_eq!(table.rows[0], vec!["a", "b", "c"]);
834 }
835
836 #[test]
837 fn parse_status_reports_validity_and_line() {
838 assert!(parse_status(r#"{"a": 1}"#, PreviewKind::Json).ok);
839 let bad = parse_status(r#"{"a": }"#, PreviewKind::Json);
840 assert!(!bad.ok);
841 assert!(bad.detail.contains("line 1"), "{}", bad.detail);
842
843 assert!(parse_status("a = 1\n", PreviewKind::Toml).ok);
844 assert!(!parse_status("a = \n", PreviewKind::Toml).ok);
845
846 assert!(parse_status("a: 1\n", PreviewKind::Yaml).ok);
847 assert!(!parse_status("a: [1, 2\n", PreviewKind::Yaml).ok);
848 }
849
850 #[test]
851 fn archive_listing_reads_zip_and_tar() {
852 let dir = tempdir().unwrap();
853
854 let zip_path = dir.path().join("a.zip");
856 let file = File::create(&zip_path).unwrap();
857 let mut zip = zip::ZipWriter::new(file);
858 let options = zip::write::SimpleFileOptions::default()
859 .compression_method(zip::CompressionMethod::Stored);
860 zip.start_file("hello.txt", options).unwrap();
861 zip.write_all(b"hi").unwrap();
862 let deflated = zip::write::SimpleFileOptions::default()
865 .compression_method(zip::CompressionMethod::Deflated);
866 zip.start_file("world.txt", deflated).unwrap();
867 zip.write_all(b"deflate me").unwrap();
868 zip.finish().unwrap();
869 let ArchiveData::Listed(listing) = list_archive(&zip_path) else {
870 panic!("expected a zip listing");
871 };
872 assert_eq!(listing.entries.len(), 2, "{listing:?}");
873 assert_eq!(listing.entries[0].name, "hello.txt");
874 assert_eq!(listing.entries[0].size, 2);
875 assert_eq!(listing.entries[1].name, "world.txt");
876 assert!(!listing.more);
877
878 let tar_path = dir.path().join("a.tar");
880 let file = File::create(&tar_path).unwrap();
881 let mut builder = tar::Builder::new(file);
882 let mut header = tar::Header::new_gnu();
883 header.set_size(2);
884 header.set_mode(0o644);
885 header.set_cksum();
886 builder.append_data(&mut header, "hello.txt", &b"hi"[..]).unwrap();
887 builder.finish().unwrap();
888 let ArchiveData::Listed(listing) = list_archive(&tar_path) else {
889 panic!("expected a tar listing");
890 };
891 assert_eq!(listing.entries[0].name, "hello.txt");
892
893 let tgz_path = dir.path().join("a.tar.gz");
895 let file = File::create(&tgz_path).unwrap();
896 let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
897 let mut builder = tar::Builder::new(encoder);
898 let mut header = tar::Header::new_gnu();
899 header.set_size(2);
900 header.set_mode(0o644);
901 header.set_cksum();
902 builder.append_data(&mut header, "hello.txt", &b"hi"[..]).unwrap();
903 builder.into_inner().unwrap().finish().unwrap();
904 let ArchiveData::Listed(listing) = list_archive(&tgz_path) else {
905 panic!("expected a tar.gz listing");
906 };
907 assert_eq!(listing.entries[0].name, "hello.txt");
908 }
909
910 #[test]
911 fn unsupported_archives_are_summarised() {
912 let dir = tempdir().unwrap();
913 let path = write(dir.path(), "a.7z", b"7z\xbc\xaf\x27\x1c whatever");
914 let ArchiveData::Unsupported(_) = list_archive(&path) else {
915 panic!("expected an unsupported summary");
916 };
917 }
918}
919