Skip to main content

hyprforge_mime/
lib.rs

1//! What type a file is, which applications can open it, and which one
2//! does by default.
3//!
4//! Nearly every question has a data file of its own, all of them
5//! freedesktop's and all of them plain text:
6//!
7//! | Question | File | Module |
8//! |---|---|---|
9//! | What type is `part.3mf`? | `mime/globs2` | [`globs`] |
10//! | What is this file, with no name to go on? | `mime/magic` | [`magic`] |
11//! | Which of those two wins? | — | [`lookup`] |
12//! | What is a `model/3mf` a kind of? | `mime/subclasses`, `mime/aliases` | [`types`] |
13//! | Which icon does a `model/3mf` have? | `mime/icons`, `mime/generic-icons` | [`icons`] |
14//! | What can open a `model/3mf`? | `applications/mimeinfo.cache` | [`apps`] |
15//! | Which one does, normally? | `mimeapps.list` | [`defaults`] |
16//!
17//! # Why this crate exists
18//!
19//! A double-clicked STL opened in Firefox. Not because anything chose
20//! that — because `xdg-open`, on a desktop it does not recognise
21//! (Hyprland is one), asks `file --mime-type`, which reads bytes and not
22//! names. An STL is `application/octet-stream` to it, a 3MF is a zip, a
23//! `.blend` is a zstd stream; none of those has a default application,
24//! and `xdg-open` answers a lookup that found nothing by walking a
25//! built-in list of web browsers. Every `.stl`, `.3mf`, `.uf2`, `.blend`
26//! and `.sh` in a real Downloads folder went that way.
27//!
28//! The database on the machine had the right answer the whole time. So
29//! this reads it.
30//!
31//! # What this is not
32//!
33//! Not a launcher, and not a fourth opinion about which application
34//! opens a file. It reads what the desktop already recorded and writes
35//! only what a person explicitly chose; running the thing is still
36//! `gio launch` with `xdg-open` behind it. Nothing here parses `Exec=`
37//! field codes, `TryExec` or `Terminal=true` — see [`apps`] for why that
38//! line is where it is.
39//!
40//! # Layering
41//!
42//! A leaf, like `hyprforge-look`: no iced, nothing Hyprland-shaped, two
43//! dependencies (`hyprforge-paths`, for the config directory and the
44//! atomic write; `hyprforge-process`, for the bounded wait
45//! `hyprforge-mimeopen` puts on `gio launch`). The file manager
46//! and the Settings app's Default apps page read it today, and any
47//! future viewer needs the same answers.
48
49pub mod apps;
50pub mod cli;
51pub mod defaults;
52pub mod globs;
53pub mod icons;
54pub mod lookup;
55pub mod magic;
56pub mod types;
57
58pub use apps::App;
59pub use globs::Globs;
60pub use lookup::{Found, How, Lookup};
61pub use magic::Magic;
62pub use types::Types;
63
64use std::path::{Path, PathBuf};
65
66/// Everything the machine says about types and the applications that
67/// open them, read once.
68///
69/// Reading it is a few small files; doing it per double-click would be a
70/// few small files per double-click, so a host builds one of these and
71/// keeps it. It is a snapshot: installing an application while a window
72/// is open will not show up until [`MimeDb::load`] is called again.
73// `PartialEq` because a host's message type derives it — the file
74// manager hands a freshly-read database back to its update loop.
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct MimeDb {
77    /// Names, contents and the type graph — everything about types, as
78    /// opposed to about applications.
79    lookup: Lookup,
80    /// Every readable desktop entry, by file name.
81    entries: std::collections::BTreeMap<String, App>,
82    /// Type to the entries that registered for it.
83    registered: std::collections::BTreeMap<String, Vec<String>>,
84    /// Type to the chosen application, user's file first.
85    chosen: std::collections::BTreeMap<String, Vec<String>>,
86    /// Applications a person added for a type themselves.
87    added: std::collections::BTreeMap<String, Vec<String>>,
88    /// Applications a person does not want offered for a type.
89    removed: std::collections::BTreeMap<String, Vec<String>>,
90    /// Which icon names each type has — see [`icons`].
91    icons: icons::Icons,
92}
93
94impl MimeDb {
95    /// Reads the database from the XDG directories.
96    ///
97    /// Never fails: a missing file is a machine without that piece,
98    /// which is a smaller answer rather than an error — the same
99    /// distinction the config loaders draw. What a caller can act on is
100    /// [`MimeDb::knows_types`].
101    pub fn load() -> MimeDb {
102        MimeDb::load_from(&data_dirs(), &mimeapps_paths())
103    }
104
105    /// [`MimeDb::load`] from given directories — the seam the tests use,
106    /// and what lets a caller point this at a fixture instead of the
107    /// machine it is running on.
108    pub fn load_from(data_dirs: &[PathBuf], mimeapps: &[PathBuf]) -> MimeDb {
109        let mut db = MimeDb {
110            lookup: Lookup::load_from(data_dirs),
111            icons: icons::Icons::load_from(data_dirs),
112            ..MimeDb::default()
113        };
114        for dir in data_dirs {
115            let applications = dir.join("applications");
116            if let Ok(text) = std::fs::read_to_string(applications.join("mimeinfo.cache")) {
117                for (mime, ids) in apps::parse_cache(&text) {
118                    let registered = db.registered.entry(mime).or_default();
119                    for id in ids {
120                        if !registered.contains(&id) {
121                            registered.push(id);
122                        }
123                    }
124                }
125            }
126            let Ok(read_dir) = std::fs::read_dir(&applications) else { continue };
127            for entry in read_dir.flatten() {
128                let path = entry.path();
129                if path.extension().is_none_or(|e| e != "desktop") {
130                    continue;
131                }
132                let Some(id) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else {
133                    continue;
134                };
135                // Earlier directories win: that is what makes a user's
136                // own copy of an entry override the system's.
137                if db.entries.contains_key(&id) {
138                    continue;
139                }
140                if let Ok(text) = std::fs::read_to_string(&path) {
141                    if let Some(app) = apps::parse_entry(&path, &text, &apps::on_path) {
142                        db.entries.insert(id, app);
143                    }
144                }
145            }
146        }
147        for path in mimeapps {
148            let Ok(text) = std::fs::read_to_string(path) else { continue };
149            for (section, into) in [
150                (defaults::parse(&text), &mut db.chosen),
151                (defaults::parse_section(&text, defaults::ADDED_SECTION), &mut db.added),
152                (defaults::parse_section(&text, defaults::REMOVED_SECTION), &mut db.removed),
153            ] {
154                for (mime, ids) in section {
155                    let list = into.entry(mime).or_default();
156                    for id in ids {
157                        if !list.contains(&id) {
158                            list.push(id);
159                        }
160                    }
161                }
162            }
163        }
164        db
165    }
166
167    /// Whether any filename rules were found at all. `false` means the
168    /// machine has no `shared-mime-info`, which a chooser should say
169    /// rather than reporting every file as unknown.
170    pub fn knows_types(&self) -> bool {
171        !self.lookup.globs.is_empty()
172    }
173
174    /// The type of a file **by name alone**, doing no I/O. `None` when
175    /// no rule matches — see [`globs::Globs::type_of`] for why that is
176    /// not octet-stream.
177    ///
178    /// This is the one to call from a UI thread, and the one to call
179    /// about a file that may not exist yet (a save dialog's filename
180    /// box). [`MimeDb::sniff`] is the same question asked properly, at
181    /// the cost of reading the file.
182    pub fn type_of(&self, path: &Path) -> Option<&str> {
183        self.lookup.globs.type_of(path)
184    }
185
186    /// What a file actually is: the name, its contents, and what kind
187    /// of thing it is on disk, in the order [`lookup`] documents.
188    ///
189    /// Reads the file, so it belongs off the UI thread for anything
190    /// that might be on a slow mount.
191    pub fn sniff(&self, path: &Path) -> Found {
192        self.lookup.of_file(path, true)
193    }
194
195    /// Whether an application registered for `parent` should be able to
196    /// open a `mime`.
197    pub fn is_subclass_of(&self, mime: &str, parent: &str) -> bool {
198        self.lookup.is_subclass_of(mime, parent)
199    }
200
201    /// Every icon name worth asking an icon theme for, for `mime`, best
202    /// first — see [`icons`]. Names only; which file each one is, is the
203    /// icon theme's question.
204    pub fn icon_names(&self, mime: &str) -> Vec<String> {
205        let mime = self.canonical(mime);
206        self.icons.names_for(mime, &self.lookup.types.ancestors(mime))
207    }
208
209    /// The name the database uses for a type, resolving an alias.
210    pub fn canonical<'a>(&'a self, mime: &'a str) -> &'a str {
211        self.lookup.types.canonical(mime)
212    }
213
214    /// Everything about types, for a caller that needs the parts
215    /// directly — the `mimetype` command does.
216    pub fn lookup(&self) -> &Lookup {
217        &self.lookup
218    }
219
220    /// Makes `app` the default for `mime`, resolving an alias first.
221    ///
222    /// A method rather than the free [`set_default`] because the read
223    /// half of this database canonicalizes (`default_for`, `apps_for`)
224    /// and the write half did not: a caller naming a type by an alias
225    /// wrote a line nothing would ever read back, and the next reload
226    /// showed the old default — which reads as "the setting didn't
227    /// take" with nothing anywhere reporting a failure.
228    pub fn set_default(&self, mime: &str, app: &str) -> std::io::Result<()> {
229        set_default(self.canonical(mime), app)
230    }
231
232    /// Forgets the default for `mime`, resolving an alias first — the
233    /// counterpart to [`MimeDb::set_default`], for a person undoing a
234    /// choice rather than making a different one.
235    ///
236    /// Worth telling apart from setting a different application: with
237    /// no line at all, the desktop falls back to whatever registered
238    /// for the type, which is where a fresh machine starts. Setting
239    /// "none" is not expressible, so "put it back how it was" has no
240    /// other spelling.
241    ///
242    /// This edits the one file this crate writes. A default that came
243    /// from a system-wide `mimeapps.list` is not this suite's to remove,
244    /// and clearing will leave it standing — see [`MimeDb::chosen_types`]
245    /// for the read side of the same distinction.
246    pub fn clear_default(&self, mime: &str) -> std::io::Result<()> {
247        clear_default(self.canonical(mime))
248    }
249
250    /// Every type this machine has a name rule for, or that something
251    /// registered for, or that a default was recorded for. Sorted, and
252    /// each type once.
253    ///
254    /// Aliases are left out: each resolves to a name already in the
255    /// list, and offering both makes one row of a search look like two
256    /// settings that could disagree.
257    pub fn known_types(&self) -> Vec<&str> {
258        let mut types: Vec<&str> = self
259            .lookup
260            .globs
261            .mimes()
262            .chain(self.registered.keys().map(String::as_str))
263            .chain(self.chosen.keys().map(String::as_str))
264            .collect();
265        types.sort_unstable();
266        types.dedup();
267        types
268    }
269
270    /// The types somebody has actually chosen an application for, from
271    /// every `mimeapps.list` that applies.
272    ///
273    /// The list nothing on this desktop shows. A `mimeapps.list`
274    /// accumulates over years, and an entry that has gone stale — the
275    /// fstl case in [`MimeDb::default_for`] — stays invisible until a
276    /// file quietly fails to open. Sorted, since it comes out of a
277    /// `BTreeMap`.
278    pub fn chosen_types(&self) -> Vec<&str> {
279        self.chosen.keys().map(String::as_str).collect()
280    }
281
282    /// Every installed application registered for `mime`, the default
283    /// first and the rest in the order the desktop lists them.
284    ///
285    /// Applications whose program is missing are left out: this is the
286    /// list a person is about to pick from, and an entry that cannot
287    /// run is a dead end rather than a choice. [`MimeDb::default_for`]
288    /// still reports a missing default, because "your default is gone"
289    /// is worth saying out loud.
290    pub fn apps_for(&self, mime: &str) -> Vec<&App> {
291        let mime = self.canonical(mime);
292        let mut ids: Vec<&String> = Vec::new();
293        for source in [self.chosen.get(mime), self.added.get(mime), self.registered.get(mime)] {
294            for id in source.into_iter().flatten() {
295                if !ids.contains(&id) {
296                    ids.push(id);
297                }
298            }
299        }
300        // A removal says "never offer this for that type", and a
301        // chooser that ignores it keeps putting back something somebody
302        // deliberately took away.
303        let removed = self.removed.get(mime);
304        ids.into_iter()
305            .filter(|id| !removed.is_some_and(|list| list.contains(id)))
306            .filter_map(|id| self.entries.get(id))
307            .filter(|app| app.installed)
308            .collect()
309    }
310
311    /// [`MimeDb::apps_for`], then the applications for every type this
312    /// one is a *kind of* — an archive manager can open a 3MF, and a
313    /// text editor can open a shell script.
314    ///
315    /// The reference implementation's `mime_applications_all`. Most
316    /// callers want [`MimeDb::candidates`] instead, which says which of
317    /// the two each application is.
318    pub fn apps_for_including_parents(&self, mime: &str) -> Vec<&App> {
319        self.candidates(mime).into_iter().map(|candidate| candidate.app).collect()
320    }
321
322    /// Everything that could open this type, ranked, and labelled with
323    /// *why* it is offered.
324    ///
325    /// One query rather than a ranking each caller works out for
326    /// itself. The file manager's chooser and `mimeopen` both ask "what
327    /// can open this", and each used to diff `apps_for` against
328    /// `apps_for_including_parents` to recover the distinction — with
329    /// the result that the two already disagreed about the order they
330    /// offered. A third consumer (a viewer, a portal) would have made
331    /// it three.
332    pub fn candidates(&self, mime: &str) -> Vec<Candidate<'_>> {
333        let default = self.default_for(mime).map(|app| app.id.as_str());
334        let own = self.apps_for(mime);
335        let mut candidates: Vec<Candidate<'_>> = own
336            .iter()
337            .map(|app| Candidate { app, made_for: true, is_default: default == Some(app.id.as_str()) })
338            .collect();
339        for parent in self.lookup.types.ancestors(mime) {
340            for app in self.apps_for(&parent) {
341                if !candidates.iter().any(|seen| seen.app.id == app.id) {
342                    candidates.push(Candidate {
343                        app,
344                        made_for: false,
345                        is_default: default == Some(app.id.as_str()),
346                    });
347                }
348            }
349        }
350        candidates
351    }
352
353    /// The application that opens `mime` today, whether or not it is
354    /// installed — so a caller can say "your default is fstl, which
355    /// isn't installed any more" instead of quietly offering something
356    /// else.
357    pub fn default_for(&self, mime: &str) -> Option<&App> {
358        let id = self.chosen.get(self.canonical(mime))?.first()?;
359        self.entries.get(id)
360    }
361
362    /// Every installed application, for the "Other…" list — a person
363    /// opening a file with something that never registered for its type
364    /// is a normal thing to want.
365    pub fn all_apps(&self) -> Vec<&App> {
366        let mut apps: Vec<&App> = self.entries.values().filter(|app| app.installed).collect();
367        apps.sort_by_key(|a| a.name.to_lowercase());
368        apps
369    }
370
371    /// One application by its entry's file name.
372    pub fn app(&self, id: &str) -> Option<&App> {
373        self.entries.get(id)
374    }
375}
376
377/// One application that could open a type, and why it is offered.
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct Candidate<'a> {
380    pub app: &'a App,
381    /// Registered for this type itself, rather than for something it is
382    /// a kind of. "Can open it" and "is meant for it" are different
383    /// claims, and a chooser should not put an archive manager above a
384    /// model viewer.
385    pub made_for: bool,
386    /// The one that opens this type today.
387    pub is_default: bool,
388}
389
390/// Makes `app` the default for `mime`, in the user's own
391/// `mimeapps.list`.
392///
393/// Prefer [`MimeDb::set_default`], which resolves an alias first. This
394/// one writes the name it is given, so a caller passing an alias
395/// records a line nothing will ever read back.
396///
397/// Writes exactly one line of that file and copies the rest through —
398/// see [`defaults`]'s own doc. The write is atomic, so an interrupted
399/// save cannot leave a half-written associations file behind.
400pub fn set_default(mime: &str, app: &str) -> std::io::Result<()> {
401    let path = user_mimeapps_path();
402    let text = match std::fs::read_to_string(&path) {
403        Ok(text) => text,
404        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
405        Err(e) => return Err(e),
406    };
407    if let Some(dir) = path.parent() {
408        std::fs::create_dir_all(dir)?;
409    }
410    hyprforge_paths::write_atomic(&path, &defaults::with_default(&text, mime, app))
411}
412
413/// Removes `mime`'s default from the user's own `mimeapps.list`.
414///
415/// Prefer [`MimeDb::clear_default`], which resolves an alias first.
416///
417/// Nothing recorded is already the state being asked for, so a missing
418/// file and a file with no line for this type are both `Ok(())` and
419/// neither writes anything. That is the one case where doing nothing is
420/// the whole job.
421pub fn clear_default(mime: &str) -> std::io::Result<()> {
422    let path = user_mimeapps_path();
423    let text = match std::fs::read_to_string(&path) {
424        Ok(text) => text,
425        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
426        Err(e) => return Err(e),
427    };
428    let Some(without) = defaults::without_default(&text, mime) else { return Ok(()) };
429    hyprforge_paths::write_atomic(&path, &without)
430}
431
432/// The one file this crate writes.
433pub fn user_mimeapps_path() -> PathBuf {
434    hyprforge_paths::config_home().join("mimeapps.list")
435}
436
437/// `$XDG_DATA_HOME` then `$XDG_DATA_DIRS`, in the order the spec gives
438/// them — public because the `mimetype` command needs the same list,
439/// and `--database` replaces exactly this — earlier is more specific. Flatpak puts its exported entries in
440/// here, which is how a flatpak application is offered at all.
441pub fn data_dirs() -> Vec<PathBuf> {
442    let mut dirs = vec![hyprforge_paths::data_home()];
443    let system = std::env::var_os("XDG_DATA_DIRS")
444        .filter(|value| !value.is_empty())
445        .unwrap_or_else(|| "/usr/local/share:/usr/share".into());
446    dirs.extend(std::env::split_paths(&system));
447    dirs
448}
449
450/// Every `mimeapps.list` that applies, most specific first. Public
451/// because the `mimeopen` command loads the database the same way.
452///
453/// The desktop-prefixed one (`hyprland-mimeapps.list`) comes before the
454/// plain one in each directory, which is the spec's way of letting a
455/// desktop differ from the machine's own choices.
456pub fn mimeapps_paths() -> Vec<PathBuf> {
457    let mut paths = Vec::new();
458    let desktops: Vec<String> = std::env::var("XDG_CURRENT_DESKTOP")
459        .unwrap_or_default()
460        .split(':')
461        .filter(|d| !d.is_empty())
462        .map(|d| d.to_lowercase())
463        .collect();
464    let mut dirs = vec![hyprforge_paths::config_home()];
465    let config_dirs = std::env::var_os("XDG_CONFIG_DIRS")
466        .filter(|value| !value.is_empty())
467        .unwrap_or_else(|| "/etc/xdg".into());
468    dirs.extend(std::env::split_paths(&config_dirs));
469    // The data directories carry them too, after the config ones.
470    dirs.extend(data_dirs().into_iter().map(|dir| dir.join("applications")));
471    for dir in dirs {
472        for desktop in &desktops {
473            paths.push(dir.join(format!("{desktop}-mimeapps.list")));
474        }
475        paths.push(dir.join("mimeapps.list"));
476    }
477    paths
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use std::fs;
484
485    /// A whole miniature desktop on disk: the mime database, two
486    /// applications, the registration cache and a user choice.
487    fn fixture() -> (tempfile::TempDir, MimeDb) {
488        let dir = tempfile::tempdir().unwrap();
489        let data = dir.path().join("data");
490        fs::create_dir_all(data.join("mime")).unwrap();
491        fs::create_dir_all(data.join("applications")).unwrap();
492        fs::write(data.join("mime/globs2"), "50:model/stl:*.stl\n50:model/3mf:*.3mf\n").unwrap();
493        fs::write(
494            data.join("applications/view3d.desktop"),
495            "[Desktop Entry]\nName=3D Viewer\nExec=sh %f\nIcon=view3d\n",
496        )
497        .unwrap();
498        fs::write(
499            data.join("applications/fstl.desktop"),
500            "[Desktop Entry]\nName=fstl\nExec=fstl-not-installed-xyz %f\n",
501        )
502        .unwrap();
503        fs::write(
504            data.join("applications/mimeinfo.cache"),
505            "[MIME Cache]\nmodel/stl=fstl.desktop;view3d.desktop;\nmodel/3mf=fstl.desktop;\n",
506        )
507        .unwrap();
508        let mimeapps = dir.path().join("mimeapps.list");
509        fs::write(&mimeapps, "[Default Applications]\nmodel/3mf=fstl.desktop\n").unwrap();
510        let db = MimeDb::load_from(&[data], &[mimeapps]);
511        (dir, db)
512    }
513
514    #[test]
515    fn a_file_is_typed_by_name_and_its_applications_found() {
516        let (_dir, db) = fixture();
517        assert!(db.knows_types());
518        assert_eq!(db.type_of(Path::new("/d/part.stl")), Some("model/stl"));
519        let apps = db.apps_for("model/stl");
520        assert_eq!(apps.len(), 1, "fstl is registered but not installed: {apps:?}");
521        assert_eq!(apps[0].name, "3D Viewer");
522    }
523
524    /// The two lists a manager screen is built on: everything that
525    /// could be searched for, and the much shorter list of what
526    /// somebody actually chose.
527    #[test]
528    fn the_types_known_and_the_types_chosen_are_different_questions() {
529        let (_dir, db) = fixture();
530        let known = db.known_types();
531        assert!(known.contains(&"model/stl"), "a glob rule names it: {known:?}");
532        assert!(known.contains(&"model/3mf"));
533        assert!(known.windows(2).all(|pair| pair[0] < pair[1]), "sorted, each once: {known:?}");
534
535        assert_eq!(db.chosen_types(), vec!["model/3mf"], "the only line in the fixture's mimeapps");
536        assert!(
537            !db.chosen_types().contains(&"model/stl"),
538            "registering for a type is not choosing one for it"
539        );
540    }
541
542    /// The failure that started this: the user's default points at
543    /// something uninstalled. It is still reported, because "your
544    /// default is gone" is the useful sentence.
545    #[test]
546    fn a_default_that_is_no_longer_installed_is_still_reported() {
547        let (_dir, db) = fixture();
548        let default = db.default_for("model/3mf").expect("the choice is still recorded");
549        assert_eq!(default.name, "fstl");
550        assert!(!default.installed);
551        assert!(db.apps_for("model/3mf").is_empty(), "and nothing is offered in its place");
552    }
553
554    /// One ranked answer, with "meant for this type" told apart from
555    /// "could open it" — rather than each caller diffing two lists.
556    #[test]
557    fn candidates_say_why_each_application_is_offered() {
558        let dir = tempfile::tempdir().unwrap();
559        let data = dir.path().join("data");
560        std::fs::create_dir_all(data.join("mime")).unwrap();
561        std::fs::create_dir_all(data.join("applications")).unwrap();
562        std::fs::write(data.join("mime/globs2"), "50:model/3mf:*.3mf\n").unwrap();
563        std::fs::write(data.join("mime/subclasses"), "model/3mf application/zip\n").unwrap();
564        for (file, name) in [("viewer.desktop", "Viewer"), ("archiver.desktop", "Archiver")] {
565            std::fs::write(
566                data.join("applications").join(file),
567                format!("[Desktop Entry]\nName={name}\nExec=sh %f\n"),
568            )
569            .unwrap();
570        }
571        std::fs::write(
572            data.join("applications/mimeinfo.cache"),
573            "[MIME Cache]\nmodel/3mf=viewer.desktop;\napplication/zip=archiver.desktop;\n",
574        )
575        .unwrap();
576        let mimeapps = dir.path().join("mimeapps.list");
577        std::fs::write(&mimeapps, "[Default Applications]\nmodel/3mf=viewer.desktop\n").unwrap();
578        let db = MimeDb::load_from(&[data], &[mimeapps]);
579
580        let candidates = db.candidates("model/3mf");
581        assert_eq!(candidates.len(), 2);
582        assert_eq!(candidates[0].app.name, "Viewer");
583        assert!(candidates[0].made_for && candidates[0].is_default);
584        assert_eq!(candidates[1].app.name, "Archiver");
585        assert!(!candidates[1].made_for, "a 3MF is a zip, but an archiver is not for 3MFs");
586        assert!(!candidates[1].is_default);
587    }
588
589    #[test]
590    fn a_type_nobody_registered_for_has_no_applications() {
591        let (_dir, db) = fixture();
592        assert!(db.apps_for("model/obj").is_empty());
593        assert_eq!(db.default_for("model/obj"), None);
594        assert_eq!(db.type_of(Path::new("/d/thing.qqq")), None);
595    }
596
597    #[test]
598    fn a_machine_with_none_of_this_says_so_rather_than_guessing() {
599        let db = MimeDb::load_from(&[PathBuf::from("/nonexistent-xyz")], &[]);
600        assert!(!db.knows_types());
601        assert!(db.all_apps().is_empty());
602    }
603
604    /// Every installed application is offerable, whatever it registered
605    /// for — "open this with something else" is a normal request.
606    #[test]
607    fn the_other_list_is_every_installed_application_by_name() {
608        let (_dir, db) = fixture();
609        let names: Vec<&str> = db.all_apps().iter().map(|a| a.name.as_str()).collect();
610        assert_eq!(names, ["3D Viewer"], "fstl is not installed");
611    }
612}