Skip to main content

tree_space/
preview.rs

1//! Inline file previews.
2//!
3//! "View Thumbnail" is really a generic inline-preview toggle: it shows a small
4//! rendering of a file below its row. This module decides *what* a file can
5//! show and loads the data; [`crate::ui::tree`] turns that data into widgets.
6//!
7//! Detection is content-based. The file's first bytes are handed to GIO's
8//! `content_type_guess` alongside its name, so extensionless and misnamed files
9//! classify correctly (`fake.png` holding text is text, not an image). A
10//! well-known extension is only consulted as a tiebreaker when the content is
11//! unrecognisably generic (an empty file, say).
12//!
13//! [`PreviewKind`] is the single registry: add a variant there, teach
14//! [`kind_for_content_type`] about it, add a [`DocumentData`] arm, and the tree
15//! gains the new preview.
16
17use 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/// What an inline preview can render for a file.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PreviewKind {
28    /// A still image, decoded to a `GdkTexture`.
29    Image,
30    /// An animated GIF: first frame still, animates on click.
31    Gif,
32    /// A video: first frame (paused) still, plays on click.
33    Video,
34    /// An audio file: no picture, just an inline transport.
35    Audio,
36    /// Plain text, source code, logs and the like.
37    Text,
38    /// A comma- or tab-separated table.
39    Csv,
40    /// A JSON document.
41    Json,
42    /// A TOML document.
43    Toml,
44    /// A YAML document.
45    Yaml,
46    /// An archive (or compressed single file).
47    Archive,
48}
49
50impl PreviewKind {
51    /// Whether this kind is rendered by a media widget rather than the
52    /// document renderer.
53    pub fn is_media(self) -> bool {
54        matches!(self, Self::Image | Self::Gif | Self::Video | Self::Audio)
55    }
56
57    /// The menu label for the preview toggle, when it should read differently
58    /// from the configured "View Thumbnail" (there is no picture to view).
59    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
70/// Bytes handed to GIO for content sniffing.
71const SNIFF_BYTES: u64 = 8192;
72/// How much of a document is read at all.
73const PREVIEW_BYTES: u64 = 64 * 1024;
74/// Lines kept for a text or structured preview. The preview is scrollable; this
75/// cap (with [`PREVIEW_BYTES`]) keeps a rebuild cheap while still showing far
76/// more than the panel height.
77const PREVIEW_LINES: usize = 300;
78/// Lines shown for a CSV/TSV preview (the first is treated as a header).
79const TABLE_ROWS: usize = 8;
80/// Columns shown for a CSV/TSV preview.
81const TABLE_COLS: usize = 6;
82/// Longest cell rendered in a table, in characters.
83const MAX_CELL_CHARS: usize = 32;
84/// Archive entries listed before summarising the rest.
85const ARCHIVE_ENTRIES: usize = 12;
86
87/// The preview `path` supports, if any. Directories return `None`: the caller
88/// previews their children instead.
89pub 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
99/// The content type for `path`, content-first.
100///
101/// GIO guesses from the name *and* the bytes together, but lets a known
102/// extension win even when the bytes disagree (`fake.png` holding text still
103/// guesses `image/png`). So we guess twice: once from the bytes alone
104/// (`data_only`) and once from name+bytes (`both`). A distinctive binary
105/// signature from the bytes wins outright; a plain-text body refines to the
106/// name's text subtype (JSON, CSV, ...) but is never overridden into a binary
107/// type; and a generic body falls back to the name. `None` means "nothing
108/// recognisable".
109fn 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        // The body is text. Keep a text subtype the name suggests, but never
121        // accept a binary type just because the extension claims one.
122        return Some(if is_textual(&both) { both } else { data_only });
123    }
124    Some(data_only)
125}
126
127/// Types that carry no information (an unknown or empty body).
128fn is_generic(ctype: &str) -> bool {
129    ctype.is_empty() || ctype == "application/octet-stream" || ctype == "application/x-zerosize"
130}
131
132/// Map a content type onto a preview kind, or `None` when we do not preview it.
133fn kind_for_content_type(ctype: &str) -> Option<PreviewKind> {
134    // SVG is text, and GdkTexture cannot rasterise it, so show the source.
135    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    // Ogg is a container that is usually audio (Vorbis); give it the player.
151    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
171/// CSV/TSV content types.
172fn 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
180/// The concrete structured format a content type names, if any.
181fn 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
194/// Whether `ctype` describes text we can show (plain, structured or tabular).
195fn is_textual(ctype: &str) -> bool {
196    is_text(ctype) || is_csv(ctype) || structured_kind(ctype).is_some()
197}
198
199/// Whether `ctype` is some kind of human-readable text (used for the generic
200/// text preview, beyond the `text/*` supertype).
201fn 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
226/// Archive and compressed-container types we at least label (not all are
227/// listable; see [`list_archive`]).
228fn 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
267/// The preview kind implied by a well-known extension. Only a fallback for when
268/// the content is generic; content sniffing wins whenever it recognises the
269/// file.
270fn 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// ---------------------------------------------------------------------------
303// document loading
304// ---------------------------------------------------------------------------
305
306/// A parsed inline document, ready to render. One variant per non-media
307/// [`PreviewKind`], so the tree's render match stays exhaustive.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub enum DocumentData {
310    /// Plain text (and the raw source of any non-table document).
311    Lines(DocumentLines),
312    /// A CSV/TSV table.
313    Table(TableData),
314    /// A structured file's raw lines plus its parse result.
315    Structured {
316        lines: DocumentLines,
317        status: ParseStatus,
318    },
319    /// An archive's table of contents, or a summary when it can't be listed.
320    Archive(ArchiveData),
321}
322
323/// Load the document behind `kind`. `None` when the file can't be read or the
324/// kind is media (media is decoded by the tree, not here).
325pub 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/// The first `max_lines` lines of `text`, each capped in length, plus whether
346/// the file continues past what is shown and the syntax spans over the joined
347/// text (empty for a language that isn't highlighted).
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct DocumentLines {
350    pub lines: Vec<String>,
351    pub more: bool,
352    /// Highlight spans over `lines.join("\n")`, in character offsets.
353    pub syntax: Vec<Span>,
354}
355
356/// A parsed table: up to `max_rows` rows of up to `max_cols` cells.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub struct TableData {
359    pub rows: Vec<Vec<String>>,
360    pub more: bool,
361}
362
363/// The result of parsing a structured document.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct ParseStatus {
366    pub ok: bool,
367    pub detail: String,
368}
369
370/// An archive's listing, or a description when its contents can't be listed.
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub enum ArchiveData {
373    Listed(ArchiveListing),
374    Unsupported(String),
375}
376
377/// The first [`ARCHIVE_ENTRIES`] entries of an archive.
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct ArchiveListing {
380    pub entries: Vec<ArchiveEntry>,
381    pub more: bool,
382}
383
384/// One archive member.
385#[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(String::from_utf8_lossy(&bytes).into_owned())
402}
403
404/// Split `text` into at most `max_lines` display lines, highlighting the result
405/// as `language`. The spans index the joined text the tree renders.
406fn lines_from(text: &str, max_lines: usize, language: Language) -> DocumentLines {
407    let mut all = text.lines();
408    let lines: Vec<String> = all.by_ref().take(max_lines).map(truncate_line).collect();
409    // `all` is not exhausted if the file continued past the cap.
410    let more = all.next().is_some();
411    let syntax = highlight::highlight(language, &lines.join("\n"));
412    DocumentLines { lines, more, syntax }
413}
414
415/// Cap a line so a minified file can't produce an unbounded label.
416fn truncate_line(line: &str) -> String {
417    const MAX_LINE_CHARS: usize = 400;
418    if line.chars().count() <= MAX_LINE_CHARS {
419        return line.to_owned();
420    }
421    let mut out: String = line.chars().take(MAX_LINE_CHARS).collect();
422    out.push('…');
423    out
424}
425
426/// Parse `text` as `kind` and describe the outcome. Every parser's message
427/// already names the line and column, so it is shown as-is.
428fn parse_status(text: &str, kind: PreviewKind) -> ParseStatus {
429    match kind {
430        PreviewKind::Json => {
431            status_of(serde_json::from_str::<serde_json::Value>(text).err(), "valid JSON")
432        }
433        PreviewKind::Toml => status_of(toml::from_str::<toml::Value>(text).err(), "valid TOML"),
434        PreviewKind::Yaml => {
435            status_of(serde_norway::from_str::<serde_norway::Value>(text).err(), "valid YAML")
436        }
437        _ => ParseStatus { ok: true, detail: String::new() },
438    }
439}
440
441/// Turn an optional parse error into a status line.
442fn status_of(error: Option<impl std::fmt::Display>, valid: &str) -> ParseStatus {
443    match error {
444        None => ParseStatus { ok: true, detail: valid.to_owned() },
445        Some(err) => ParseStatus {
446            ok: false,
447            detail: first_line(&err.to_string()).to_owned(),
448        },
449    }
450}
451
452/// The first line of a (possibly multi-line) error message, trimmed.
453fn first_line(message: &str) -> &str {
454    message.lines().next().unwrap_or(message).trim()
455}
456
457fn read_table(path: &Path, max_rows: usize, max_cols: usize) -> Option<TableData> {
458    let text = read_text(path, PREVIEW_BYTES)?;
459    // Sniff the delimiter from the first line rather than trusting the
460    // extension: a `.csv` exported with tabs should still line up.
461    let delimiter = first_line_of(&text)
462        .map(|line| {
463            if line.matches('\t').count() > line.matches(',').count() {
464                '\t'
465            } else {
466                ','
467            }
468        })
469        .unwrap_or(',');
470
471    let mut lines = text.lines();
472    let rows: Vec<Vec<String>> = lines
473        .by_ref()
474        .take(max_rows)
475        .map(|line| {
476            split_fields(line, delimiter)
477                .into_iter()
478                .take(max_cols)
479                .map(|cell| truncate_cell(&cell))
480                .collect()
481        })
482        .collect();
483    if rows.is_empty() {
484        return None;
485    }
486    let more = lines.next().is_some();
487    Some(TableData { rows, more })
488}
489
490fn first_line_of(text: &str) -> Option<&str> {
491    text.lines().find(|line| !line.trim().is_empty())
492}
493
494/// Split one CSV/TSV line, honouring double-quoted fields (`""` is a literal
495/// quote). Deliberately forgiving: a preview does not need full RFC 4180.
496fn split_fields(line: &str, delimiter: char) -> Vec<String> {
497    let mut fields = Vec::new();
498    let mut field = String::new();
499    let mut quoted = false;
500    let mut chars = line.chars().peekable();
501    while let Some(c) = chars.next() {
502        match c {
503            '"' if quoted && chars.peek() == Some(&'"') => {
504                field.push('"');
505                chars.next();
506            }
507            '"' => quoted = !quoted,
508            c if c == delimiter && !quoted => fields.push(std::mem::take(&mut field)),
509            c => field.push(c),
510        }
511    }
512    fields.push(field);
513    fields
514}
515
516fn truncate_cell(cell: &str) -> String {
517    let cell = cell.trim();
518    if cell.chars().count() <= MAX_CELL_CHARS {
519        return cell.to_owned();
520    }
521    let mut out: String = cell.chars().take(MAX_CELL_CHARS).collect();
522    out.push('…');
523    out
524}
525
526// ---------------------------------------------------------------------------
527// archives
528// ---------------------------------------------------------------------------
529
530/// Which listing strategy an archive needs.
531enum ArchiveFlavor {
532    Zip,
533    /// A gzip stream wrapping a tar.
534    GzipTar,
535    Tar,
536    /// A container we recognise but do not list.
537    Other(&'static str),
538}
539
540fn list_archive(path: &Path) -> ArchiveData {
541    match archive_flavor(path) {
542        ArchiveFlavor::Zip => list_zip(path),
543        ArchiveFlavor::GzipTar => list_tar(path, true),
544        ArchiveFlavor::Tar => list_tar(path, false),
545        ArchiveFlavor::Other(name) => {
546            ArchiveData::Unsupported(format!("{name} archive, contents not listed"))
547        }
548    }
549}
550
551fn archive_flavor(path: &Path) -> ArchiveFlavor {
552    if let Some(ctype) = raw_content_type(path) {
553        match ctype.as_str() {
554            "application/zip" => return ArchiveFlavor::Zip,
555            "application/gzip" | "application/x-gzip" | "application/x-compressed-tar" => {
556                return ArchiveFlavor::GzipTar;
557            }
558            "application/x-tar" | "application/x-gtar" => return ArchiveFlavor::Tar,
559            _ => {}
560        }
561    }
562    match path
563        .extension()
564        .and_then(|ext| ext.to_str())
565        .map(|ext| ext.to_ascii_lowercase())
566        .as_deref()
567    {
568        Some("zip" | "jar" | "apk" | "epub") => ArchiveFlavor::Zip,
569        Some("gz" | "tgz") => ArchiveFlavor::GzipTar,
570        Some("tar") => ArchiveFlavor::Tar,
571        Some("bz2") => ArchiveFlavor::Other("bzip2"),
572        Some("xz") => ArchiveFlavor::Other("xz"),
573        Some("zst") => ArchiveFlavor::Other("zstandard"),
574        Some("7z") => ArchiveFlavor::Other("7z"),
575        Some("rar") => ArchiveFlavor::Other("rar"),
576        Some("iso") => ArchiveFlavor::Other("ISO image"),
577        _ => ArchiveFlavor::Other("unknown"),
578    }
579}
580
581fn list_zip(path: &Path) -> ArchiveData {
582    let Ok(file) = File::open(path) else {
583        return ArchiveData::Unsupported("zip, unreadable".to_owned());
584    };
585    let Ok(mut archive) = zip::ZipArchive::new(std::io::BufReader::new(file)) else {
586        return ArchiveData::Unsupported("zip, unreadable".to_owned());
587    };
588    let total = archive.len();
589    let mut entries = Vec::new();
590    for index in 0..total.min(ARCHIVE_ENTRIES) {
591        let Ok(entry) = archive.by_index(index) else {
592            continue;
593        };
594        entries.push(ArchiveEntry {
595            name: entry.name().to_owned(),
596            size: entry.size(),
597            is_dir: entry.is_dir(),
598        });
599    }
600    ArchiveData::Listed(ArchiveListing {
601        more: total > entries.len(),
602        entries,
603    })
604}
605
606fn list_tar(path: &Path, compressed: bool) -> ArchiveData {
607    let Ok(file) = File::open(path) else {
608        return ArchiveData::Unsupported("tar, unreadable".to_owned());
609    };
610    let reader: Box<dyn Read> = if compressed {
611        Box::new(flate2::read::GzDecoder::new(file))
612    } else {
613        Box::new(file)
614    };
615    let mut archive = tar::Archive::new(reader);
616    let Ok(mut iter) = archive.entries() else {
617        return ArchiveData::Unsupported("tar, unreadable".to_owned());
618    };
619
620    let mut entries = Vec::new();
621    let mut more = false;
622    while entries.len() < ARCHIVE_ENTRIES {
623        match iter.next() {
624            Some(Ok(entry)) => {
625                let name = entry
626                    .path()
627                    .map(|p| p.to_string_lossy().into_owned())
628                    .unwrap_or_default();
629                let header = entry.header();
630                entries.push(ArchiveEntry {
631                    name,
632                    size: header.size().unwrap_or(0),
633                    is_dir: header.entry_type().is_dir(),
634                });
635            }
636            Some(Err(_)) => break,
637            None => break,
638        }
639    }
640    if entries.len() == ARCHIVE_ENTRIES {
641        // One more header tells us whether to say "and more".
642        more = matches!(iter.next(), Some(Ok(_)));
643    }
644    if entries.is_empty() {
645        let label = if compressed { "gzip" } else { "tar" };
646        return ArchiveData::Unsupported(format!("{label}, not a tar archive"));
647    }
648    ArchiveData::Listed(ArchiveListing { entries, more })
649}
650
651#[cfg(test)]
652mod tests {
653    use std::io::Write;
654
655    use tempfile::tempdir;
656
657    use super::*;
658
659    fn write(dir: &Path, name: &str, contents: &[u8]) -> std::path::PathBuf {
660        let path = dir.join(name);
661        std::fs::write(&path, contents).unwrap();
662        path
663    }
664
665    #[test]
666    fn detection_prefers_content_over_extension() {
667        let dir = tempdir().unwrap();
668        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'];
669        // Text masquerading as an image must not be treated as one.
670        let fake = write(dir.path(), "fake.png", b"hello, this is plain text\n");
671        assert_eq!(detect(&fake), Some(PreviewKind::Text));
672        // A real image under a misleading name is still an image.
673        let named_text = write(dir.path(), "actually-an-image.txt", png);
674        assert_eq!(detect(&named_text), Some(PreviewKind::Image));
675        // A real image with no extension at all is an image.
676        let extensionless = write(dir.path(), "picture", png);
677        assert_eq!(detect(&extensionless), Some(PreviewKind::Image));
678        // A text file named like an archive is text, not an archive.
679        let text_zip = write(dir.path(), "notes.zip", b"just some notes\n");
680        assert_eq!(detect(&text_zip), Some(PreviewKind::Text));
681    }
682
683    #[test]
684    fn detection_classifies_documents_and_archives() {
685        let dir = tempdir().unwrap();
686        let cases: &[(&str, &[u8], PreviewKind)] = &[
687            ("a.json", br#"{"a": 1}"#, PreviewKind::Json),
688            ("a.toml", b"a = 1\n", PreviewKind::Toml),
689            ("a.yaml", b"a: 1\n", PreviewKind::Yaml),
690            ("a.csv", b"a,b\n1,2\n", PreviewKind::Csv),
691            ("a.tsv", b"a\tb\n1\t2\n", PreviewKind::Csv),
692            ("a.txt", b"plain text\n", PreviewKind::Text),
693            ("a.rs", b"fn main() {}\n", PreviewKind::Text),
694            ("a.ts", b"const x: number = 1;\n", PreviewKind::Text),
695            ("a.csv", b"a,b\n1,2\n", PreviewKind::Csv),
696        ];
697        for (name, contents, expected) in cases {
698            let path = write(dir.path(), name, contents);
699            assert_eq!(detect(&path), Some(*expected), "{name}");
700        }
701        assert_eq!(detect(dir.path()), None, "directories are not previews");
702        assert_eq!(detect(Path::new("")), None);
703    }
704
705    #[test]
706    fn detection_can_be_forced_by_extension_when_content_is_generic() {
707        // A zero-length file has no content to sniff, so the name decides.
708        let dir = tempdir().unwrap();
709        let path = write(dir.path(), "empty.json", b"");
710        assert_eq!(detect(&path), Some(PreviewKind::Json));
711    }
712
713    #[test]
714    fn lines_are_capped_and_note_more() {
715        let text = "one\ntwo\nthree\nfour\n";
716        let lines = lines_from(text, 2, Language::Plain);
717        assert_eq!(lines.lines, vec!["one", "two"]);
718        assert!(lines.more);
719
720        let lines = lines_from(text, 99, Language::Plain);
721        assert_eq!(lines.lines.len(), 4);
722        assert!(!lines.more);
723        assert!(lines.syntax.is_empty());
724    }
725
726    #[test]
727    fn text_preview_carries_syntax_spans() {
728        let dir = tempfile::tempdir().unwrap();
729        let path = dir.path().join("demo.rs");
730        std::fs::write(&path, "fn main() { let x = 1; // c\n}\n").unwrap();
731        let Some(DocumentData::Lines(lines)) = load_document(&path, PreviewKind::Text) else {
732            panic!("expected text lines");
733        };
734        assert!(!lines.syntax.is_empty());
735        // The first span is the `fn` keyword.
736        assert_eq!(lines.syntax[0].start, 0);
737        assert_eq!(lines.syntax[0].class, crate::highlight::TokenClass::Keyword);
738        // A plain `.txt` file gets no spans.
739        let plain = dir.path().join("note.txt");
740        std::fs::write(&plain, "just text\n").unwrap();
741        let Some(DocumentData::Lines(lines)) = load_document(&plain, PreviewKind::Text) else {
742            panic!("expected text lines");
743        };
744        assert!(lines.syntax.is_empty());
745    }
746
747    #[test]
748    fn long_lines_are_truncated() {
749        let long = "x".repeat(1000);
750        let line = truncate_line(&long);
751        assert!(line.chars().count() < 1000);
752        assert!(line.ends_with('…'));
753    }
754
755    #[test]
756    fn csv_fields_honour_quotes() {
757        let fields = split_fields(r#"a,"b,c",d"#, ',');
758        assert_eq!(fields, vec!["a", "b,c", "d"]);
759        let fields = split_fields(r#""say ""hi""",x"#, ',');
760        assert_eq!(fields, vec![r#"say "hi""#, "x"]);
761    }
762
763    #[test]
764    fn table_sniffs_the_delimiter_and_limits_cells() {
765        let dir = tempdir().unwrap();
766        let path = write(dir.path(), "grid.csv", b"a,b,c\n1,2,3\n");
767        let Some(DocumentData::Table(table)) = load_document(&path, PreviewKind::Csv) else {
768            panic!("expected a table");
769        };
770        assert_eq!(table.rows[0], vec!["a", "b", "c"]);
771        assert_eq!(table.rows[1], vec!["1", "2", "3"]);
772
773        // A tab-separated file with a comma-free first line still lines up.
774        let path = write(dir.path(), "grid.csv", b"a\tb\tc\n1\t2\t3\n");
775        let Some(DocumentData::Table(table)) = load_document(&path, PreviewKind::Csv) else {
776            panic!("expected a table");
777        };
778        assert_eq!(table.rows[0], vec!["a", "b", "c"]);
779    }
780
781    #[test]
782    fn parse_status_reports_validity_and_line() {
783        assert!(parse_status(r#"{"a": 1}"#, PreviewKind::Json).ok);
784        let bad = parse_status(r#"{"a": }"#, PreviewKind::Json);
785        assert!(!bad.ok);
786        assert!(bad.detail.contains("line 1"), "{}", bad.detail);
787
788        assert!(parse_status("a = 1\n", PreviewKind::Toml).ok);
789        assert!(!parse_status("a = \n", PreviewKind::Toml).ok);
790
791        assert!(parse_status("a: 1\n", PreviewKind::Yaml).ok);
792        assert!(!parse_status("a: [1, 2\n", PreviewKind::Yaml).ok);
793    }
794
795    #[test]
796    fn archive_listing_reads_zip_and_tar() {
797        let dir = tempdir().unwrap();
798
799        // zip
800        let zip_path = dir.path().join("a.zip");
801        let file = File::create(&zip_path).unwrap();
802        let mut zip = zip::ZipWriter::new(file);
803        let options = zip::write::SimpleFileOptions::default()
804            .compression_method(zip::CompressionMethod::Stored);
805        zip.start_file("hello.txt", options).unwrap();
806        zip.write_all(b"hi").unwrap();
807        // A Deflate-compressed member must be listed too (the reader needs the
808        // `deflate` backend for it).
809        let deflated = zip::write::SimpleFileOptions::default()
810            .compression_method(zip::CompressionMethod::Deflated);
811        zip.start_file("world.txt", deflated).unwrap();
812        zip.write_all(b"deflate me").unwrap();
813        zip.finish().unwrap();
814        let ArchiveData::Listed(listing) = list_archive(&zip_path) else {
815            panic!("expected a zip listing");
816        };
817        assert_eq!(listing.entries.len(), 2, "{listing:?}");
818        assert_eq!(listing.entries[0].name, "hello.txt");
819        assert_eq!(listing.entries[0].size, 2);
820        assert_eq!(listing.entries[1].name, "world.txt");
821        assert!(!listing.more);
822
823        // plain tar
824        let tar_path = dir.path().join("a.tar");
825        let file = File::create(&tar_path).unwrap();
826        let mut builder = tar::Builder::new(file);
827        let mut header = tar::Header::new_gnu();
828        header.set_size(2);
829        header.set_mode(0o644);
830        header.set_cksum();
831        builder.append_data(&mut header, "hello.txt", &b"hi"[..]).unwrap();
832        builder.finish().unwrap();
833        let ArchiveData::Listed(listing) = list_archive(&tar_path) else {
834            panic!("expected a tar listing");
835        };
836        assert_eq!(listing.entries[0].name, "hello.txt");
837
838        // gzip'd tar
839        let tgz_path = dir.path().join("a.tar.gz");
840        let file = File::create(&tgz_path).unwrap();
841        let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
842        let mut builder = tar::Builder::new(encoder);
843        let mut header = tar::Header::new_gnu();
844        header.set_size(2);
845        header.set_mode(0o644);
846        header.set_cksum();
847        builder.append_data(&mut header, "hello.txt", &b"hi"[..]).unwrap();
848        builder.into_inner().unwrap().finish().unwrap();
849        let ArchiveData::Listed(listing) = list_archive(&tgz_path) else {
850            panic!("expected a tar.gz listing");
851        };
852        assert_eq!(listing.entries[0].name, "hello.txt");
853    }
854
855    #[test]
856    fn unsupported_archives_are_summarised() {
857        let dir = tempdir().unwrap();
858        let path = write(dir.path(), "a.7z", b"7z\xbc\xaf\x27\x1c whatever");
859        let ArchiveData::Unsupported(_) = list_archive(&path) else {
860            panic!("expected an unsupported summary");
861        };
862    }
863}
864