use std::path::{Path, PathBuf};
use std::process::Command;
use crate::preview::process::run_command_capture_stdout_cancellable;
pub(super) fn detect_mime_type(path: &Path, canceled: &impl Fn() -> bool) -> Option<String> {
if let Some(mime) = mime_from_xdg_database(path) {
return Some(mime);
}
if canceled() {
return None;
}
let mut cmd = Command::new("xdg-mime");
cmd.args(["query", "filetype"]).arg(path);
if let Some(out) = run_command_capture_stdout_cancellable(cmd, "open-with-mime", canceled) {
let s = String::from_utf8_lossy(&out).trim().to_string();
if !s.is_empty() {
return Some(s);
}
}
if canceled() {
return None;
}
let mut cmd = Command::new("file");
cmd.args(["--mime-type", "-b"]).arg(path);
if let Some(out) = run_command_capture_stdout_cancellable(cmd, "open-with-mime-fb", canceled) {
let s = String::from_utf8_lossy(&out).trim().to_string();
if !s.is_empty() {
return Some(s);
}
}
None
}
fn mime_from_xdg_database(path: &Path) -> Option<String> {
mime_from_data_dirs(path, &super::xdg_data_dirs())
}
pub(super) fn mime_from_data_dirs(path: &Path, data_dirs: &[PathBuf]) -> Option<String> {
let ext = path.extension()?.to_str()?.to_lowercase();
let target = format!("*.{ext}");
let mut best_weight = -1i32;
let mut best_mime: Option<String> = None;
for dir in data_dirs {
let globs2 = dir.join("mime/globs2");
let Ok(contents) = std::fs::read_to_string(&globs2) else {
continue;
};
for line in contents.lines() {
let line = line.trim();
if line.starts_with('#') || line.is_empty() {
continue;
}
let mut parts = line.splitn(3, ':');
let (Some(w_str), Some(mime), Some(pattern)) =
(parts.next(), parts.next(), parts.next())
else {
continue;
};
if pattern != target {
continue;
}
let weight: i32 = w_str.parse().unwrap_or(50);
if weight > best_weight {
best_weight = weight;
best_mime = Some(mime.to_string());
}
}
}
if best_mime.is_some() {
return best_mime;
}
for dir in data_dirs {
let globs = dir.join("mime/globs");
let Ok(contents) = std::fs::read_to_string(&globs) else {
continue;
};
for line in contents.lines() {
let line = line.trim();
if line.starts_with('#') || line.is_empty() {
continue;
}
let Some((mime, pattern)) = line.split_once(':') else {
continue;
};
if pattern == target {
return Some(mime.to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn unique_dir(label: &str) -> PathBuf {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
std::env::temp_dir().join(format!(
"elio-mime-test-{label}-{}-{nanos}",
std::process::id()
))
}
fn write_globs2(dir: &Path, content: &str) {
let mime_dir = dir.join("mime");
fs::create_dir_all(&mime_dir).expect("create mime dir");
fs::write(mime_dir.join("globs2"), content).expect("write globs2");
}
fn write_globs(dir: &Path, content: &str) {
let mime_dir = dir.join("mime");
fs::create_dir_all(&mime_dir).expect("create mime dir");
fs::write(mime_dir.join("globs"), content).expect("write globs");
}
#[test]
fn finds_mime_type_from_globs2() {
let dir = unique_dir("globs2-basic");
write_globs2(&dir, "50:text/markdown:*.md\n");
let result = mime_from_data_dirs(Path::new("/any/file.md"), std::slice::from_ref(&dir));
let _ = fs::remove_dir_all(&dir);
assert_eq!(result.as_deref(), Some("text/markdown"));
}
#[test]
fn falls_back_to_globs_when_globs2_absent() {
let dir = unique_dir("globs-fallback");
write_globs(&dir, "text/plain:*.txt\n");
let result = mime_from_data_dirs(Path::new("/any/file.txt"), std::slice::from_ref(&dir));
let _ = fs::remove_dir_all(&dir);
assert_eq!(result.as_deref(), Some("text/plain"));
}
#[test]
fn globs2_higher_weight_wins_over_lower_weight() {
let dir = unique_dir("globs2-weight");
write_globs2(
&dir,
"40:text/x-markdown:*.md\n\
60:text/markdown:*.md\n\
50:text/plain:*.txt\n",
);
let result = mime_from_data_dirs(Path::new("/docs/readme.md"), std::slice::from_ref(&dir));
let _ = fs::remove_dir_all(&dir);
assert_eq!(result.as_deref(), Some("text/markdown"));
}
#[test]
fn globs2_weights_compared_across_multiple_data_dirs() {
let dir1 = unique_dir("multi-dir-low");
let dir2 = unique_dir("multi-dir-high");
write_globs2(&dir1, "30:text/x-markdown:*.md\n");
write_globs2(&dir2, "70:text/markdown:*.md\n");
let result =
mime_from_data_dirs(Path::new("/docs/readme.md"), &[dir1.clone(), dir2.clone()]);
let _ = fs::remove_dir_all(&dir1);
let _ = fs::remove_dir_all(&dir2);
assert_eq!(result.as_deref(), Some("text/markdown"));
}
#[test]
fn globs_first_match_in_priority_order_wins() {
let dir1 = unique_dir("globs-priority-high");
let dir2 = unique_dir("globs-priority-low");
write_globs(&dir1, "text/plain:*.txt\n");
write_globs(&dir2, "text/x-log:*.txt\n");
let result =
mime_from_data_dirs(Path::new("/var/log/app.txt"), &[dir1.clone(), dir2.clone()]);
let _ = fs::remove_dir_all(&dir1);
let _ = fs::remove_dir_all(&dir2);
assert_eq!(result.as_deref(), Some("text/plain"));
}
#[test]
fn returns_none_for_unknown_extension() {
let dir = unique_dir("no-match");
write_globs2(&dir, "50:text/plain:*.txt\n");
let result = mime_from_data_dirs(
Path::new("/tmp/file.xyzzy_elio_test"),
std::slice::from_ref(&dir),
);
let _ = fs::remove_dir_all(&dir);
assert!(result.is_none());
}
#[test]
fn returns_none_when_data_dirs_empty() {
let result = mime_from_data_dirs(Path::new("/tmp/file.txt"), &[]);
assert!(result.is_none());
}
#[test]
fn returns_none_for_path_with_no_extension() {
let dir = unique_dir("no-ext");
write_globs2(&dir, "50:text/plain:*.txt\n");
let result = mime_from_data_dirs(Path::new("/usr/bin/ls"), std::slice::from_ref(&dir));
let _ = fs::remove_dir_all(&dir);
assert!(result.is_none());
}
#[test]
fn mime_from_xdg_database_returns_expected_type_for_common_extensions() {
let has_db = super::super::xdg_data_dirs()
.iter()
.any(|d| d.join("mime/globs2").exists() || d.join("mime/globs").exists());
if !has_db {
return;
}
let result = mime_from_xdg_database(Path::new("/any/path/image.png"));
assert_eq!(
result.as_deref(),
Some("image/png"),
"expected image/png for .png extension"
);
}
}