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}