pub mod apps;
pub mod cli;
pub mod defaults;
pub mod globs;
pub mod icons;
pub mod lookup;
pub mod magic;
pub mod types;
pub use apps::App;
pub use globs::Globs;
pub use lookup::{Found, How, Lookup};
pub use magic::Magic;
pub use types::Types;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MimeDb {
lookup: Lookup,
entries: std::collections::BTreeMap<String, App>,
registered: std::collections::BTreeMap<String, Vec<String>>,
chosen: std::collections::BTreeMap<String, Vec<String>>,
added: std::collections::BTreeMap<String, Vec<String>>,
removed: std::collections::BTreeMap<String, Vec<String>>,
icons: icons::Icons,
}
impl MimeDb {
pub fn load() -> MimeDb {
MimeDb::load_from(&data_dirs(), &mimeapps_paths())
}
pub fn load_from(data_dirs: &[PathBuf], mimeapps: &[PathBuf]) -> MimeDb {
let mut db = MimeDb {
lookup: Lookup::load_from(data_dirs),
icons: icons::Icons::load_from(data_dirs),
..MimeDb::default()
};
for dir in data_dirs {
let applications = dir.join("applications");
if let Ok(text) = std::fs::read_to_string(applications.join("mimeinfo.cache")) {
for (mime, ids) in apps::parse_cache(&text) {
let registered = db.registered.entry(mime).or_default();
for id in ids {
if !registered.contains(&id) {
registered.push(id);
}
}
}
}
let Ok(read_dir) = std::fs::read_dir(&applications) else { continue };
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "desktop") {
continue;
}
let Some(id) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else {
continue;
};
if db.entries.contains_key(&id) {
continue;
}
if let Ok(text) = std::fs::read_to_string(&path) {
if let Some(app) = apps::parse_entry(&path, &text, &apps::on_path) {
db.entries.insert(id, app);
}
}
}
}
for path in mimeapps {
let Ok(text) = std::fs::read_to_string(path) else { continue };
for (section, into) in [
(defaults::parse(&text), &mut db.chosen),
(defaults::parse_section(&text, defaults::ADDED_SECTION), &mut db.added),
(defaults::parse_section(&text, defaults::REMOVED_SECTION), &mut db.removed),
] {
for (mime, ids) in section {
let list = into.entry(mime).or_default();
for id in ids {
if !list.contains(&id) {
list.push(id);
}
}
}
}
}
db
}
pub fn knows_types(&self) -> bool {
!self.lookup.globs.is_empty()
}
pub fn type_of(&self, path: &Path) -> Option<&str> {
self.lookup.globs.type_of(path)
}
pub fn sniff(&self, path: &Path) -> Found {
self.lookup.of_file(path, true)
}
pub fn is_subclass_of(&self, mime: &str, parent: &str) -> bool {
self.lookup.is_subclass_of(mime, parent)
}
pub fn icon_names(&self, mime: &str) -> Vec<String> {
let mime = self.canonical(mime);
self.icons.names_for(mime, &self.lookup.types.ancestors(mime))
}
pub fn canonical<'a>(&'a self, mime: &'a str) -> &'a str {
self.lookup.types.canonical(mime)
}
pub fn lookup(&self) -> &Lookup {
&self.lookup
}
pub fn set_default(&self, mime: &str, app: &str) -> std::io::Result<()> {
set_default(self.canonical(mime), app)
}
pub fn clear_default(&self, mime: &str) -> std::io::Result<()> {
clear_default(self.canonical(mime))
}
pub fn known_types(&self) -> Vec<&str> {
let mut types: Vec<&str> = self
.lookup
.globs
.mimes()
.chain(self.registered.keys().map(String::as_str))
.chain(self.chosen.keys().map(String::as_str))
.collect();
types.sort_unstable();
types.dedup();
types
}
pub fn chosen_types(&self) -> Vec<&str> {
self.chosen.keys().map(String::as_str).collect()
}
pub fn apps_for(&self, mime: &str) -> Vec<&App> {
let mime = self.canonical(mime);
let mut ids: Vec<&String> = Vec::new();
for source in [self.chosen.get(mime), self.added.get(mime), self.registered.get(mime)] {
for id in source.into_iter().flatten() {
if !ids.contains(&id) {
ids.push(id);
}
}
}
let removed = self.removed.get(mime);
ids.into_iter()
.filter(|id| !removed.is_some_and(|list| list.contains(id)))
.filter_map(|id| self.entries.get(id))
.filter(|app| app.installed)
.collect()
}
pub fn apps_for_including_parents(&self, mime: &str) -> Vec<&App> {
self.candidates(mime).into_iter().map(|candidate| candidate.app).collect()
}
pub fn candidates(&self, mime: &str) -> Vec<Candidate<'_>> {
let default = self.default_for(mime).map(|app| app.id.as_str());
let own = self.apps_for(mime);
let mut candidates: Vec<Candidate<'_>> = own
.iter()
.map(|app| Candidate { app, made_for: true, is_default: default == Some(app.id.as_str()) })
.collect();
for parent in self.lookup.types.ancestors(mime) {
for app in self.apps_for(&parent) {
if !candidates.iter().any(|seen| seen.app.id == app.id) {
candidates.push(Candidate {
app,
made_for: false,
is_default: default == Some(app.id.as_str()),
});
}
}
}
candidates
}
pub fn default_for(&self, mime: &str) -> Option<&App> {
let id = self.chosen.get(self.canonical(mime))?.first()?;
self.entries.get(id)
}
pub fn all_apps(&self) -> Vec<&App> {
let mut apps: Vec<&App> = self.entries.values().filter(|app| app.installed).collect();
apps.sort_by_key(|a| a.name.to_lowercase());
apps
}
pub fn app(&self, id: &str) -> Option<&App> {
self.entries.get(id)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate<'a> {
pub app: &'a App,
pub made_for: bool,
pub is_default: bool,
}
pub fn set_default(mime: &str, app: &str) -> std::io::Result<()> {
let path = user_mimeapps_path();
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e),
};
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
hyprforge_paths::write_atomic(&path, &defaults::with_default(&text, mime, app))
}
pub fn clear_default(mime: &str) -> std::io::Result<()> {
let path = user_mimeapps_path();
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
let Some(without) = defaults::without_default(&text, mime) else { return Ok(()) };
hyprforge_paths::write_atomic(&path, &without)
}
pub fn user_mimeapps_path() -> PathBuf {
hyprforge_paths::config_home().join("mimeapps.list")
}
pub fn data_dirs() -> Vec<PathBuf> {
let mut dirs = vec![hyprforge_paths::data_home()];
let system = std::env::var_os("XDG_DATA_DIRS")
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "/usr/local/share:/usr/share".into());
dirs.extend(std::env::split_paths(&system));
dirs
}
pub fn mimeapps_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
let desktops: Vec<String> = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.split(':')
.filter(|d| !d.is_empty())
.map(|d| d.to_lowercase())
.collect();
let mut dirs = vec![hyprforge_paths::config_home()];
let config_dirs = std::env::var_os("XDG_CONFIG_DIRS")
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "/etc/xdg".into());
dirs.extend(std::env::split_paths(&config_dirs));
dirs.extend(data_dirs().into_iter().map(|dir| dir.join("applications")));
for dir in dirs {
for desktop in &desktops {
paths.push(dir.join(format!("{desktop}-mimeapps.list")));
}
paths.push(dir.join("mimeapps.list"));
}
paths
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn fixture() -> (tempfile::TempDir, MimeDb) {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
fs::create_dir_all(data.join("mime")).unwrap();
fs::create_dir_all(data.join("applications")).unwrap();
fs::write(data.join("mime/globs2"), "50:model/stl:*.stl\n50:model/3mf:*.3mf\n").unwrap();
fs::write(
data.join("applications/view3d.desktop"),
"[Desktop Entry]\nName=3D Viewer\nExec=sh %f\nIcon=view3d\n",
)
.unwrap();
fs::write(
data.join("applications/fstl.desktop"),
"[Desktop Entry]\nName=fstl\nExec=fstl-not-installed-xyz %f\n",
)
.unwrap();
fs::write(
data.join("applications/mimeinfo.cache"),
"[MIME Cache]\nmodel/stl=fstl.desktop;view3d.desktop;\nmodel/3mf=fstl.desktop;\n",
)
.unwrap();
let mimeapps = dir.path().join("mimeapps.list");
fs::write(&mimeapps, "[Default Applications]\nmodel/3mf=fstl.desktop\n").unwrap();
let db = MimeDb::load_from(&[data], &[mimeapps]);
(dir, db)
}
#[test]
fn a_file_is_typed_by_name_and_its_applications_found() {
let (_dir, db) = fixture();
assert!(db.knows_types());
assert_eq!(db.type_of(Path::new("/d/part.stl")), Some("model/stl"));
let apps = db.apps_for("model/stl");
assert_eq!(apps.len(), 1, "fstl is registered but not installed: {apps:?}");
assert_eq!(apps[0].name, "3D Viewer");
}
#[test]
fn the_types_known_and_the_types_chosen_are_different_questions() {
let (_dir, db) = fixture();
let known = db.known_types();
assert!(known.contains(&"model/stl"), "a glob rule names it: {known:?}");
assert!(known.contains(&"model/3mf"));
assert!(known.windows(2).all(|pair| pair[0] < pair[1]), "sorted, each once: {known:?}");
assert_eq!(db.chosen_types(), vec!["model/3mf"], "the only line in the fixture's mimeapps");
assert!(
!db.chosen_types().contains(&"model/stl"),
"registering for a type is not choosing one for it"
);
}
#[test]
fn a_default_that_is_no_longer_installed_is_still_reported() {
let (_dir, db) = fixture();
let default = db.default_for("model/3mf").expect("the choice is still recorded");
assert_eq!(default.name, "fstl");
assert!(!default.installed);
assert!(db.apps_for("model/3mf").is_empty(), "and nothing is offered in its place");
}
#[test]
fn candidates_say_why_each_application_is_offered() {
let dir = tempfile::tempdir().unwrap();
let data = dir.path().join("data");
std::fs::create_dir_all(data.join("mime")).unwrap();
std::fs::create_dir_all(data.join("applications")).unwrap();
std::fs::write(data.join("mime/globs2"), "50:model/3mf:*.3mf\n").unwrap();
std::fs::write(data.join("mime/subclasses"), "model/3mf application/zip\n").unwrap();
for (file, name) in [("viewer.desktop", "Viewer"), ("archiver.desktop", "Archiver")] {
std::fs::write(
data.join("applications").join(file),
format!("[Desktop Entry]\nName={name}\nExec=sh %f\n"),
)
.unwrap();
}
std::fs::write(
data.join("applications/mimeinfo.cache"),
"[MIME Cache]\nmodel/3mf=viewer.desktop;\napplication/zip=archiver.desktop;\n",
)
.unwrap();
let mimeapps = dir.path().join("mimeapps.list");
std::fs::write(&mimeapps, "[Default Applications]\nmodel/3mf=viewer.desktop\n").unwrap();
let db = MimeDb::load_from(&[data], &[mimeapps]);
let candidates = db.candidates("model/3mf");
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].app.name, "Viewer");
assert!(candidates[0].made_for && candidates[0].is_default);
assert_eq!(candidates[1].app.name, "Archiver");
assert!(!candidates[1].made_for, "a 3MF is a zip, but an archiver is not for 3MFs");
assert!(!candidates[1].is_default);
}
#[test]
fn a_type_nobody_registered_for_has_no_applications() {
let (_dir, db) = fixture();
assert!(db.apps_for("model/obj").is_empty());
assert_eq!(db.default_for("model/obj"), None);
assert_eq!(db.type_of(Path::new("/d/thing.qqq")), None);
}
#[test]
fn a_machine_with_none_of_this_says_so_rather_than_guessing() {
let db = MimeDb::load_from(&[PathBuf::from("/nonexistent-xyz")], &[]);
assert!(!db.knows_types());
assert!(db.all_apps().is_empty());
}
#[test]
fn the_other_list_is_every_installed_application_by_name() {
let (_dir, db) = fixture();
let names: Vec<&str> = db.all_apps().iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, ["3D Viewer"], "fstl is not installed");
}
}