use std::fs::File;
use std::io::Read;
use std::path::Path;
use relm4::gtk::gio;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreviewKind {
Image,
Gif,
Video,
Audio,
Text,
Csv,
Json,
Toml,
Yaml,
Archive,
}
impl PreviewKind {
pub fn is_media(self) -> bool {
matches!(self, Self::Image | Self::Gif | Self::Video | Self::Audio)
}
pub fn menu_label(self) -> Option<&'static str> {
match self {
Self::Audio => Some("Show Player"),
Self::Video => Some("Show Video"),
Self::Text | Self::Csv | Self::Json | Self::Toml | Self::Yaml => Some("Show Preview"),
Self::Archive => Some("Show Contents"),
Self::Image | Self::Gif => None,
}
}
}
const SNIFF_BYTES: u64 = 8192;
const PREVIEW_BYTES: u64 = 64 * 1024;
const PREVIEW_LINES: usize = 20;
const TABLE_ROWS: usize = 8;
const TABLE_COLS: usize = 6;
const MAX_CELL_CHARS: usize = 32;
const ARCHIVE_ENTRIES: usize = 12;
pub fn detect(path: &Path) -> Option<PreviewKind> {
if !path.is_file() {
return None;
}
match raw_content_type(path) {
Some(ctype) => kind_for_content_type(&ctype).or_else(|| extension_kind(path)),
None => extension_kind(path),
}
}
fn raw_content_type(path: &Path) -> Option<String> {
let data = read_prefix(path, SNIFF_BYTES).unwrap_or_default();
let (data_only, _) = gio::content_type_guess(None::<&Path>, Some(&data[..]));
let (both, _) = gio::content_type_guess(Some(path), Some(&data[..]));
let data_only = data_only.to_string();
let both = both.to_string();
if is_generic(&data_only) {
return (!is_generic(&both)).then_some(both);
}
if is_textual(&data_only) {
return Some(if is_textual(&both) { both } else { data_only });
}
Some(data_only)
}
fn is_generic(ctype: &str) -> bool {
ctype.is_empty() || ctype == "application/octet-stream" || ctype == "application/x-zerosize"
}
fn kind_for_content_type(ctype: &str) -> Option<PreviewKind> {
if ctype == "image/svg+xml" {
return Some(PreviewKind::Text);
}
if ctype == "image/gif" {
return Some(PreviewKind::Gif);
}
if gio::functions::content_type_is_a(ctype, "image/*") {
return Some(PreviewKind::Image);
}
if gio::functions::content_type_is_a(ctype, "video/*") {
return Some(PreviewKind::Video);
}
if gio::functions::content_type_is_a(ctype, "audio/*") {
return cfg!(feature = "audio").then_some(PreviewKind::Audio);
}
if cfg!(feature = "audio")
&& matches!(ctype, "application/ogg" | "application/x-ogg")
{
return Some(PreviewKind::Audio);
}
if is_archive(ctype) {
return Some(PreviewKind::Archive);
}
if is_csv(ctype) {
return Some(PreviewKind::Csv);
}
if let Some(kind) = structured_kind(ctype) {
return Some(kind);
}
if is_text(ctype) {
return Some(PreviewKind::Text);
}
None
}
fn is_csv(ctype: &str) -> bool {
matches!(
ctype,
"text/csv" | "text/tab-separated-values" | "application/csv" | "text/x-csv"
| "text/x-comma-separated-values"
)
}
fn structured_kind(ctype: &str) -> Option<PreviewKind> {
match ctype {
"application/json" | "text/json" | "application/x-json" | "application/ld+json"
| "application/x-ndjson" => Some(PreviewKind::Json),
"application/toml" | "text/toml" | "text/x-toml" | "application/x-toml" => {
Some(PreviewKind::Toml)
}
"application/yaml" | "text/yaml" | "text/x-yaml" | "application/x-yaml"
| "application/x-yml" | "text/x-yml" => Some(PreviewKind::Yaml),
_ => None,
}
}
fn is_textual(ctype: &str) -> bool {
is_text(ctype) || is_csv(ctype) || structured_kind(ctype).is_some()
}
fn is_text(ctype: &str) -> bool {
gio::functions::content_type_is_a(ctype, "text/*")
|| matches!(
ctype,
"application/xml"
| "application/javascript"
| "application/x-javascript"
| "application/ecmascript"
| "application/x-shellscript"
| "application/x-perl"
| "application/x-python"
| "application/x-ruby"
| "application/x-tcl"
| "application/x-awk"
| "application/x-php"
| "application/x-httpd-php"
| "application/x-lua"
| "application/x-haskell"
| "application/x-tex"
| "application/x-texinfo"
| "application/x-desktop"
| "application/x-wine-extension-ini"
)
}
fn is_archive(ctype: &str) -> bool {
matches!(
ctype,
"application/zip"
| "application/x-tar"
| "application/x-gtar"
| "application/gzip"
| "application/x-gzip"
| "application/x-compressed-tar"
| "application/x-bzip2"
| "application/x-bzip"
| "application/x-bzip-compressed-tar"
| "application/x-xz"
| "application/x-xz-compressed-tar"
| "application/x-lzma"
| "application/x-lz4"
| "application/x-lzip"
| "application/zstd"
| "application/x-zstd"
| "application/x-7z-compressed"
| "application/vnd.rar"
| "application/x-rar"
| "application/x-rar-compressed"
| "application/x-cpio"
| "application/x-archive"
| "application/x-arj"
| "application/x-iso9660-image"
| "application/x-cd-image"
| "application/x-apple-diskimage"
| "application/vnd.debian.binary-package"
| "application/x-deb"
| "application/x-rpm"
| "application/java-archive"
| "application/epub+zip"
| "application/vnd.android.package-archive"
| "application/x-compress"
)
}
fn extension_kind(path: &Path) -> Option<PreviewKind> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
if cfg!(feature = "audio")
&& matches!(
ext.as_str(),
"mp3" | "m4a" | "m4b" | "aac" | "flac" | "wav" | "opus" | "oga" | "wma" | "aif"
| "aiff" | "alac"
)
{
return Some(PreviewKind::Audio);
}
Some(match ext.as_str() {
"gif" => PreviewKind::Gif,
"jpg" | "jpeg" | "png" | "webp" | "bmp" | "tif" | "tiff" | "avif" | "heic" | "heif" => {
PreviewKind::Image
}
"mp4" | "m4v" | "mkv" | "webm" | "mov" | "avi" | "wmv" | "flv" | "ogv" | "ogg" | "mpg"
| "mpeg" | "3gp" | "m2ts" => PreviewKind::Video,
"zip" | "tar" | "gz" | "tgz" | "bz2" | "xz" | "7z" | "rar" | "zst" | "lz4" | "lz"
| "iso" | "deb" | "rpm" | "jar" | "epub" | "apk" | "cpio" | "ar" => PreviewKind::Archive,
"csv" | "tsv" => PreviewKind::Csv,
"json" => PreviewKind::Json,
"toml" => PreviewKind::Toml,
"yaml" | "yml" => PreviewKind::Yaml,
"txt" | "md" | "markdown" | "log" | "rs" | "go" | "c" | "h" | "cpp" | "hpp" | "py" | "js"
| "ts" | "sh" | "ini" | "conf" | "cfg" | "xml" | "html" | "css" | "svg" => {
PreviewKind::Text
}
_ => return None,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DocumentData {
Lines(DocumentLines),
Table(TableData),
Structured {
lines: DocumentLines,
status: ParseStatus,
},
Archive(ArchiveData),
}
pub fn load_document(path: &Path, kind: PreviewKind) -> Option<DocumentData> {
match kind {
PreviewKind::Text => {
let text = read_text(path, PREVIEW_BYTES)?;
Some(DocumentData::Lines(lines_from(&text, PREVIEW_LINES)))
}
PreviewKind::Json | PreviewKind::Toml | PreviewKind::Yaml => {
let text = read_text(path, PREVIEW_BYTES)?;
Some(DocumentData::Structured {
lines: lines_from(&text, PREVIEW_LINES),
status: parse_status(&text, kind),
})
}
PreviewKind::Csv => read_table(path, TABLE_ROWS, TABLE_COLS).map(DocumentData::Table),
PreviewKind::Archive => Some(DocumentData::Archive(list_archive(path))),
PreviewKind::Image | PreviewKind::Gif | PreviewKind::Video | PreviewKind::Audio => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentLines {
pub lines: Vec<String>,
pub more: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableData {
pub rows: Vec<Vec<String>>,
pub more: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseStatus {
pub ok: bool,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArchiveData {
Listed(ArchiveListing),
Unsupported(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveListing {
pub entries: Vec<ArchiveEntry>,
pub more: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveEntry {
pub name: String,
pub size: u64,
pub is_dir: bool,
}
fn read_prefix(path: &Path, max: u64) -> Option<Vec<u8>> {
let file = File::open(path).ok()?;
let mut buf = Vec::new();
file.take(max).read_to_end(&mut buf).ok()?;
Some(buf)
}
fn read_text(path: &Path, max: u64) -> Option<String> {
let bytes = read_prefix(path, max)?;
Some(String::from_utf8_lossy(&bytes).into_owned())
}
fn lines_from(text: &str, max_lines: usize) -> DocumentLines {
let mut all = text.lines();
let lines: Vec<String> = all.by_ref().take(max_lines).map(truncate_line).collect();
let more = all.next().is_some();
DocumentLines { lines, more }
}
fn truncate_line(line: &str) -> String {
const MAX_LINE_CHARS: usize = 400;
if line.chars().count() <= MAX_LINE_CHARS {
return line.to_owned();
}
let mut out: String = line.chars().take(MAX_LINE_CHARS).collect();
out.push('…');
out
}
fn parse_status(text: &str, kind: PreviewKind) -> ParseStatus {
match kind {
PreviewKind::Json => {
status_of(serde_json::from_str::<serde_json::Value>(text).err(), "valid JSON")
}
PreviewKind::Toml => status_of(toml::from_str::<toml::Value>(text).err(), "valid TOML"),
PreviewKind::Yaml => {
status_of(serde_norway::from_str::<serde_norway::Value>(text).err(), "valid YAML")
}
_ => ParseStatus { ok: true, detail: String::new() },
}
}
fn status_of(error: Option<impl std::fmt::Display>, valid: &str) -> ParseStatus {
match error {
None => ParseStatus { ok: true, detail: valid.to_owned() },
Some(err) => ParseStatus {
ok: false,
detail: first_line(&err.to_string()).to_owned(),
},
}
}
fn first_line(message: &str) -> &str {
message.lines().next().unwrap_or(message).trim()
}
fn read_table(path: &Path, max_rows: usize, max_cols: usize) -> Option<TableData> {
let text = read_text(path, PREVIEW_BYTES)?;
let delimiter = first_line_of(&text)
.map(|line| {
if line.matches('\t').count() > line.matches(',').count() {
'\t'
} else {
','
}
})
.unwrap_or(',');
let mut lines = text.lines();
let rows: Vec<Vec<String>> = lines
.by_ref()
.take(max_rows)
.map(|line| {
split_fields(line, delimiter)
.into_iter()
.take(max_cols)
.map(|cell| truncate_cell(&cell))
.collect()
})
.collect();
if rows.is_empty() {
return None;
}
let more = lines.next().is_some();
Some(TableData { rows, more })
}
fn first_line_of(text: &str) -> Option<&str> {
text.lines().find(|line| !line.trim().is_empty())
}
fn split_fields(line: &str, delimiter: char) -> Vec<String> {
let mut fields = Vec::new();
let mut field = String::new();
let mut quoted = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if quoted && chars.peek() == Some(&'"') => {
field.push('"');
chars.next();
}
'"' => quoted = !quoted,
c if c == delimiter && !quoted => fields.push(std::mem::take(&mut field)),
c => field.push(c),
}
}
fields.push(field);
fields
}
fn truncate_cell(cell: &str) -> String {
let cell = cell.trim();
if cell.chars().count() <= MAX_CELL_CHARS {
return cell.to_owned();
}
let mut out: String = cell.chars().take(MAX_CELL_CHARS).collect();
out.push('…');
out
}
enum ArchiveFlavor {
Zip,
GzipTar,
Tar,
Other(&'static str),
}
fn list_archive(path: &Path) -> ArchiveData {
match archive_flavor(path) {
ArchiveFlavor::Zip => list_zip(path),
ArchiveFlavor::GzipTar => list_tar(path, true),
ArchiveFlavor::Tar => list_tar(path, false),
ArchiveFlavor::Other(name) => {
ArchiveData::Unsupported(format!("{name} archive, contents not listed"))
}
}
}
fn archive_flavor(path: &Path) -> ArchiveFlavor {
if let Some(ctype) = raw_content_type(path) {
match ctype.as_str() {
"application/zip" => return ArchiveFlavor::Zip,
"application/gzip" | "application/x-gzip" | "application/x-compressed-tar" => {
return ArchiveFlavor::GzipTar;
}
"application/x-tar" | "application/x-gtar" => return ArchiveFlavor::Tar,
_ => {}
}
}
match path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.as_deref()
{
Some("zip" | "jar" | "apk" | "epub") => ArchiveFlavor::Zip,
Some("gz" | "tgz") => ArchiveFlavor::GzipTar,
Some("tar") => ArchiveFlavor::Tar,
Some("bz2") => ArchiveFlavor::Other("bzip2"),
Some("xz") => ArchiveFlavor::Other("xz"),
Some("zst") => ArchiveFlavor::Other("zstandard"),
Some("7z") => ArchiveFlavor::Other("7z"),
Some("rar") => ArchiveFlavor::Other("rar"),
Some("iso") => ArchiveFlavor::Other("ISO image"),
_ => ArchiveFlavor::Other("unknown"),
}
}
fn list_zip(path: &Path) -> ArchiveData {
let Ok(file) = File::open(path) else {
return ArchiveData::Unsupported("zip, unreadable".to_owned());
};
let Ok(mut archive) = zip::ZipArchive::new(std::io::BufReader::new(file)) else {
return ArchiveData::Unsupported("zip, unreadable".to_owned());
};
let total = archive.len();
let mut entries = Vec::new();
for index in 0..total.min(ARCHIVE_ENTRIES) {
let Ok(entry) = archive.by_index(index) else {
continue;
};
entries.push(ArchiveEntry {
name: entry.name().to_owned(),
size: entry.size(),
is_dir: entry.is_dir(),
});
}
ArchiveData::Listed(ArchiveListing {
more: total > entries.len(),
entries,
})
}
fn list_tar(path: &Path, compressed: bool) -> ArchiveData {
let Ok(file) = File::open(path) else {
return ArchiveData::Unsupported("tar, unreadable".to_owned());
};
let reader: Box<dyn Read> = if compressed {
Box::new(flate2::read::GzDecoder::new(file))
} else {
Box::new(file)
};
let mut archive = tar::Archive::new(reader);
let Ok(mut iter) = archive.entries() else {
return ArchiveData::Unsupported("tar, unreadable".to_owned());
};
let mut entries = Vec::new();
let mut more = false;
while entries.len() < ARCHIVE_ENTRIES {
match iter.next() {
Some(Ok(entry)) => {
let name = entry
.path()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let header = entry.header();
entries.push(ArchiveEntry {
name,
size: header.size().unwrap_or(0),
is_dir: header.entry_type().is_dir(),
});
}
Some(Err(_)) => break,
None => break,
}
}
if entries.len() == ARCHIVE_ENTRIES {
more = matches!(iter.next(), Some(Ok(_)));
}
if entries.is_empty() {
let label = if compressed { "gzip" } else { "tar" };
return ArchiveData::Unsupported(format!("{label}, not a tar archive"));
}
ArchiveData::Listed(ArchiveListing { entries, more })
}
#[cfg(test)]
mod tests {
use std::io::Write;
use tempfile::tempdir;
use super::*;
fn write(dir: &Path, name: &str, contents: &[u8]) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, contents).unwrap();
path
}
#[test]
fn detection_prefers_content_over_extension() {
let dir = tempdir().unwrap();
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'];
let fake = write(dir.path(), "fake.png", b"hello, this is plain text\n");
assert_eq!(detect(&fake), Some(PreviewKind::Text));
let named_text = write(dir.path(), "actually-an-image.txt", png);
assert_eq!(detect(&named_text), Some(PreviewKind::Image));
let extensionless = write(dir.path(), "picture", png);
assert_eq!(detect(&extensionless), Some(PreviewKind::Image));
let text_zip = write(dir.path(), "notes.zip", b"just some notes\n");
assert_eq!(detect(&text_zip), Some(PreviewKind::Text));
}
#[test]
fn detection_classifies_documents_and_archives() {
let dir = tempdir().unwrap();
let cases: &[(&str, &[u8], PreviewKind)] = &[
("a.json", br#"{"a": 1}"#, PreviewKind::Json),
("a.toml", b"a = 1\n", PreviewKind::Toml),
("a.yaml", b"a: 1\n", PreviewKind::Yaml),
("a.csv", b"a,b\n1,2\n", PreviewKind::Csv),
("a.tsv", b"a\tb\n1\t2\n", PreviewKind::Csv),
("a.txt", b"plain text\n", PreviewKind::Text),
("a.rs", b"fn main() {}\n", PreviewKind::Text),
("a.ts", b"const x: number = 1;\n", PreviewKind::Text),
("a.csv", b"a,b\n1,2\n", PreviewKind::Csv),
];
for (name, contents, expected) in cases {
let path = write(dir.path(), name, contents);
assert_eq!(detect(&path), Some(*expected), "{name}");
}
assert_eq!(detect(dir.path()), None, "directories are not previews");
assert_eq!(detect(Path::new("")), None);
}
#[test]
fn detection_can_be_forced_by_extension_when_content_is_generic() {
let dir = tempdir().unwrap();
let path = write(dir.path(), "empty.json", b"");
assert_eq!(detect(&path), Some(PreviewKind::Json));
}
#[test]
fn lines_are_capped_and_note_more() {
let text = "one\ntwo\nthree\nfour\n";
let lines = lines_from(text, 2);
assert_eq!(lines.lines, vec!["one", "two"]);
assert!(lines.more);
let lines = lines_from(text, 99);
assert_eq!(lines.lines.len(), 4);
assert!(!lines.more);
}
#[test]
fn long_lines_are_truncated() {
let long = "x".repeat(1000);
let line = truncate_line(&long);
assert!(line.chars().count() < 1000);
assert!(line.ends_with('…'));
}
#[test]
fn csv_fields_honour_quotes() {
let fields = split_fields(r#"a,"b,c",d"#, ',');
assert_eq!(fields, vec!["a", "b,c", "d"]);
let fields = split_fields(r#""say ""hi""",x"#, ',');
assert_eq!(fields, vec![r#"say "hi""#, "x"]);
}
#[test]
fn table_sniffs_the_delimiter_and_limits_cells() {
let dir = tempdir().unwrap();
let path = write(dir.path(), "grid.csv", b"a,b,c\n1,2,3\n");
let Some(DocumentData::Table(table)) = load_document(&path, PreviewKind::Csv) else {
panic!("expected a table");
};
assert_eq!(table.rows[0], vec!["a", "b", "c"]);
assert_eq!(table.rows[1], vec!["1", "2", "3"]);
let path = write(dir.path(), "grid.csv", b"a\tb\tc\n1\t2\t3\n");
let Some(DocumentData::Table(table)) = load_document(&path, PreviewKind::Csv) else {
panic!("expected a table");
};
assert_eq!(table.rows[0], vec!["a", "b", "c"]);
}
#[test]
fn parse_status_reports_validity_and_line() {
assert!(parse_status(r#"{"a": 1}"#, PreviewKind::Json).ok);
let bad = parse_status(r#"{"a": }"#, PreviewKind::Json);
assert!(!bad.ok);
assert!(bad.detail.contains("line 1"), "{}", bad.detail);
assert!(parse_status("a = 1\n", PreviewKind::Toml).ok);
assert!(!parse_status("a = \n", PreviewKind::Toml).ok);
assert!(parse_status("a: 1\n", PreviewKind::Yaml).ok);
assert!(!parse_status("a: [1, 2\n", PreviewKind::Yaml).ok);
}
#[test]
fn archive_listing_reads_zip_and_tar() {
let dir = tempdir().unwrap();
let zip_path = dir.path().join("a.zip");
let file = File::create(&zip_path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("hello.txt", options).unwrap();
zip.write_all(b"hi").unwrap();
let deflated = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("world.txt", deflated).unwrap();
zip.write_all(b"deflate me").unwrap();
zip.finish().unwrap();
let ArchiveData::Listed(listing) = list_archive(&zip_path) else {
panic!("expected a zip listing");
};
assert_eq!(listing.entries.len(), 2, "{listing:?}");
assert_eq!(listing.entries[0].name, "hello.txt");
assert_eq!(listing.entries[0].size, 2);
assert_eq!(listing.entries[1].name, "world.txt");
assert!(!listing.more);
let tar_path = dir.path().join("a.tar");
let file = File::create(&tar_path).unwrap();
let mut builder = tar::Builder::new(file);
let mut header = tar::Header::new_gnu();
header.set_size(2);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, "hello.txt", &b"hi"[..]).unwrap();
builder.finish().unwrap();
let ArchiveData::Listed(listing) = list_archive(&tar_path) else {
panic!("expected a tar listing");
};
assert_eq!(listing.entries[0].name, "hello.txt");
let tgz_path = dir.path().join("a.tar.gz");
let file = File::create(&tgz_path).unwrap();
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::fast());
let mut builder = tar::Builder::new(encoder);
let mut header = tar::Header::new_gnu();
header.set_size(2);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, "hello.txt", &b"hi"[..]).unwrap();
builder.into_inner().unwrap().finish().unwrap();
let ArchiveData::Listed(listing) = list_archive(&tgz_path) else {
panic!("expected a tar.gz listing");
};
assert_eq!(listing.entries[0].name, "hello.txt");
}
#[test]
fn unsupported_archives_are_summarised() {
let dir = tempdir().unwrap();
let path = write(dir.path(), "a.7z", b"7z\xbc\xaf\x27\x1c whatever");
let ArchiveData::Unsupported(_) = list_archive(&path) else {
panic!("expected an unsupported summary");
};
}
}