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