Skip to main content

hyprforge_mime/
defaults.rs

1//! Which application opens a type, and changing it.
2//!
3//! `mimeapps.list` is the freedesktop file that records the choice. It
4//! is read from several places (the user's own, then the system's) and
5//! written in exactly one: `$XDG_CONFIG_HOME/mimeapps.list`.
6//!
7//! # This file is hand-edited, so it is edited by hand
8//!
9//! CLAUDE.md's rule about `files-config.toml` applies here with more
10//! force, because this file is not ours: people put comments in it,
11//! group related types together, and keep sections this code has never
12//! heard of. [`with_default`] therefore changes one line and copies
13//! every other byte through — no parse-and-reserialise, which would
14//! silently reorder a person's file and drop their notes the first time
15//! they changed a default from a menu.
16//!
17//! `[Added Associations]` and `[Removed Associations]` are *read* —
18//! they change what a chooser may offer — and never written: adding or
19//! removing an association is not something this suite has a reason to
20//! do on anyone's behalf. A section beyond those three is neither read
21//! nor touched, which is the only safe thing to do with it.
22
23use std::collections::BTreeMap;
24
25const DEFAULTS_SECTION: &str = "[Default Applications]";
26/// Applications a user added for a type beyond what the desktop entries
27/// themselves registered.
28pub const ADDED_SECTION: &str = "[Added Associations]";
29/// Applications a user does not want offered for a type, whatever the
30/// desktop entry claims.
31pub const REMOVED_SECTION: &str = "[Removed Associations]";
32
33/// The `[Default Applications]` entries of one `mimeapps.list`.
34///
35/// The value is a list because the spec allows fallbacks: the first
36/// entry that is actually installed wins.
37pub fn parse(text: &str) -> BTreeMap<String, Vec<String>> {
38    parse_section(text, DEFAULTS_SECTION)
39}
40
41/// Any one section of a `mimeapps.list` — or of `mimeinfo.cache`,
42/// which has the same shape and is read by this too (see
43/// [`crate::apps::parse_cache`]).
44///
45/// The three that matter here are the defaults, the associations a
46/// person added, and the ones they removed — see [`ADDED_SECTION`] and
47/// [`REMOVED_SECTION`]. A removal is not a smaller kind of default: it
48/// says "never offer this for that type", and a chooser that ignores it
49/// keeps putting back something somebody deliberately took away.
50///
51/// A key that appears twice in one section keeps the entries of both,
52/// first occurrence first — the same "the earlier rule wins" the glob
53/// matcher settled on, so that a caller taking `.first()` gets the
54/// answer it would get from two files in directory order.
55pub fn parse_section(text: &str, section: &str) -> BTreeMap<String, Vec<String>> {
56    let mut defaults: BTreeMap<String, Vec<String>> = BTreeMap::new();
57    let mut in_section = false;
58    for line in text.lines() {
59        let line = line.trim();
60        if line.starts_with('[') {
61            in_section = line == section;
62            continue;
63        }
64        if !in_section || line.is_empty() || line.starts_with('#') {
65            continue;
66        }
67        let Some((mime, entries)) = line.split_once('=') else { continue };
68        let entries = entries.split(';').map(str::trim).filter(|e| !e.is_empty());
69        let known: &mut Vec<String> = defaults.entry(mime.trim().to_string()).or_default();
70        for entry in entries {
71            if !known.iter().any(|seen| seen == entry) {
72                known.push(entry.to_string());
73            }
74        }
75    }
76    defaults
77}
78
79/// `text` with `mime` set to open in `app` — the whole file back, one
80/// line different.
81///
82/// Three cases, in the order they are met in the wild:
83///
84/// - The type already has a line: that line's value is replaced, where
85///   it sits. Moving it to the bottom would reorder a file someone
86///   grouped on purpose.
87/// - There is a `[Default Applications]` section but no line for this
88///   type: the line is added at the end of that section, before any
89///   blank line that separates it from the next one.
90/// - There is no such section: it is appended, after a blank line.
91///
92/// The file's own line endings are not preserved, because every writer
93/// of this format uses `\n` and pretending otherwise would mean carrying
94/// a guess through every branch above.
95pub fn with_default(text: &str, mime: &str, app: &str) -> String {
96    let new_line = format!("{mime}={app}");
97    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
98
99    // Where the section starts, and where it ends (the next section, or
100    // the end of the file).
101    let start = lines.iter().position(|line| line.trim() == DEFAULTS_SECTION);
102    let Some(start) = start else {
103        if !lines.is_empty() && !lines.last().is_some_and(|l| l.trim().is_empty()) {
104            lines.push(String::new());
105        }
106        lines.push(DEFAULTS_SECTION.to_string());
107        lines.push(new_line);
108        return finish(lines);
109    };
110    let end = lines[start + 1..]
111        .iter()
112        .position(|line| line.trim_start().starts_with('['))
113        .map(|offset| start + 1 + offset)
114        .unwrap_or(lines.len());
115
116    let existing = lines[start + 1..end].iter().position(|line| {
117        line.split_once('=').is_some_and(|(key, _)| key.trim() == mime) && !line.trim_start().starts_with('#')
118    });
119    match existing {
120        Some(offset) => lines[start + 1 + offset] = new_line,
121        // After the last line with something on it, so the new entry
122        // joins the section rather than landing past the blank line that
123        // ends it.
124        None => {
125            let last = lines[start + 1..end]
126                .iter()
127                .rposition(|line| !line.trim().is_empty())
128                .map(|offset| start + 2 + offset)
129                .unwrap_or(start + 1);
130            lines.insert(last, new_line);
131        }
132    }
133    finish(lines)
134}
135
136/// `text` with `mime`'s default removed, or `None` when it had none.
137///
138/// The counterpart to [`with_default`], and under the same rule: one
139/// line goes and every other byte is copied through. `None` rather than
140/// an unchanged copy, so a caller can decline to rewrite a file it has
141/// nothing to say about — a save that only rewrites the line endings of
142/// somebody's hand-kept file is still a save they did not ask for.
143///
144/// Removing the line rather than setting it to nothing. `type=` with an
145/// empty value is a line every other reader on the desktop has to have
146/// an opinion about, and the specification gives it none.
147///
148/// *Every* line for the type in that section, not the first. A repeated
149/// key accumulates here (see [`parse_section`]), so leaving one behind
150/// would mean a cleared default that is still set — the failure being
151/// cleared in the first place.
152///
153/// Only `[Default Applications]`. An association a person added is a
154/// different statement and is not theirs to lose because a default was
155/// cleared.
156pub fn without_default(text: &str, mime: &str) -> Option<String> {
157    let lines: Vec<&str> = text.lines().collect();
158    let start = lines.iter().position(|line| line.trim() == DEFAULTS_SECTION)?;
159    let end = lines[start + 1..]
160        .iter()
161        .position(|line| line.trim_start().starts_with('['))
162        .map(|offset| start + 1 + offset)
163        .unwrap_or(lines.len());
164
165    let mut kept: Vec<String> = Vec::with_capacity(lines.len());
166    let mut removed = false;
167    for (index, line) in lines.iter().enumerate() {
168        let in_section = index > start && index < end;
169        let is_ours = in_section
170            && !line.trim_start().starts_with('#')
171            && line.split_once('=').is_some_and(|(key, _)| key.trim() == mime);
172        match is_ours {
173            true => removed = true,
174            false => kept.push(line.to_string()),
175        }
176    }
177    removed.then(|| finish(kept))
178}
179
180/// A text file ends with a newline.
181fn finish(lines: Vec<String>) -> String {
182    let mut text = lines.join("\n");
183    text.push('\n');
184    text
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    const REAL: &str = "\
192# my associations
193[Default Applications]
194text/html=google-chrome.desktop
195x-scheme-handler/http=google-chrome.desktop
196model/stl=view3d.desktop
197
198[Added Associations]
199model/3mf=fstl.desktop;
200";
201
202    #[test]
203    fn the_defaults_section_reads_as_type_to_applications() {
204        let defaults = parse(REAL);
205        assert_eq!(defaults.get("text/html").unwrap(), &["google-chrome.desktop"]);
206        assert_eq!(defaults.get("model/stl").unwrap(), &["view3d.desktop"]);
207        assert_eq!(defaults.get("model/3mf"), None, "that one is an association, not a default");
208    }
209
210    #[test]
211    fn the_other_two_sections_read_the_same_way() {
212        let added = parse_section(REAL, ADDED_SECTION);
213        assert_eq!(added.get("model/3mf").unwrap(), &["fstl.desktop"]);
214        assert!(parse_section(REAL, REMOVED_SECTION).is_empty(), "none in this file");
215        assert!(
216            !added.contains_key("text/html"),
217            "a default is not an association; the sections stay apart"
218        );
219    }
220
221    #[test]
222    fn clearing_removes_the_line_and_leaves_the_rest_alone() {
223        let out = without_default(REAL, "model/stl").expect("that type had a default");
224        assert!(!out.contains("model/stl"), "the line is gone");
225        assert!(out.contains("# my associations"), "and the comment above it is not");
226        assert!(out.contains("text/html=google-chrome.desktop"), "nor its neighbours");
227        assert!(out.contains("model/3mf=fstl.desktop;"), "nor the association in the next section");
228    }
229
230    /// Nothing to remove is not a reason to rewrite somebody's file:
231    /// `None` rather than an unchanged copy, so a caller can decline
232    /// the write entirely.
233    #[test]
234    fn clearing_a_type_that_was_never_set_writes_nothing() {
235        assert_eq!(without_default(REAL, "image/png"), None);
236        assert_eq!(without_default("", "image/png"), None, "nor when there is no section at all");
237        assert_eq!(
238            without_default(REAL, "model/3mf"),
239            None,
240            "an added association is not a default, and clearing must not take it"
241        );
242    }
243
244    /// A key written twice accumulates on the way in, so leaving one
245    /// occurrence behind would be a cleared default that is still set.
246    #[test]
247    fn clearing_takes_every_occurrence_of_the_type() {
248        let text = "[Default Applications]\nimage/png=a.desktop\nimage/gif=c.desktop\nimage/png=b.desktop\n";
249        let out = without_default(text, "image/png").expect("it was set");
250        assert!(!out.contains("image/png"), "both lines go: {out}");
251        assert!(out.contains("image/gif=c.desktop"));
252        assert!(!parse(&out).contains_key("image/png"));
253    }
254
255    #[test]
256    fn a_fallback_list_keeps_its_order() {
257        let defaults = parse("[Default Applications]\nimage/png=a.desktop;b.desktop;\n");
258        assert_eq!(defaults.get("image/png").unwrap(), &["a.desktop", "b.desktop"]);
259    }
260
261    /// A key written twice in one section keeps both, in the order
262    /// written, without repeating an entry that appears in both.
263    #[test]
264    fn a_repeated_key_accumulates_rather_than_replacing() {
265        let defaults = parse(
266            "[Default Applications]\nimage/png=a.desktop\nimage/png=b.desktop;a.desktop\n",
267        );
268        assert_eq!(defaults.get("image/png").unwrap(), &["a.desktop", "b.desktop"]);
269    }
270
271    /// `mimeinfo.cache` is the same shape and is read by the same
272    /// scanner — one implementation, one answer about repeats.
273    #[test]
274    fn the_registration_cache_reads_through_the_same_scanner() {
275        let cache = crate::apps::parse_cache(
276            "[MIME Cache]\nmodel/stl=viewer.desktop;slicer.desktop;\n",
277        );
278        assert_eq!(cache.get("model/stl").unwrap(), &["viewer.desktop", "slicer.desktop"]);
279    }
280
281    /// The property this module exists for: everything that is not the
282    /// changed line survives, byte for byte.
283    #[test]
284    fn changing_a_default_leaves_every_other_line_alone() {
285        let after = with_default(REAL, "model/stl", "BambuStudio.desktop");
286        assert!(after.contains("# my associations"), "the comment stays");
287        assert!(after.contains("[Added Associations]\nmodel/3mf=fstl.desktop;"), "so does a section we do not understand");
288        assert!(after.contains("model/stl=BambuStudio.desktop"));
289        assert!(!after.contains("model/stl=view3d.desktop"));
290        // The changed line is where it was, not moved to the end.
291        let lines: Vec<&str> = after.lines().collect();
292        assert_eq!(lines[4], "model/stl=BambuStudio.desktop");
293    }
294
295    #[test]
296    fn a_new_type_joins_the_section_rather_than_landing_after_it() {
297        let after = with_default(REAL, "model/3mf", "view3d.desktop");
298        let lines: Vec<&str> = after.lines().collect();
299        let section = lines.iter().position(|l| *l == "[Default Applications]").unwrap();
300        let added = lines.iter().position(|l| *l == "model/3mf=view3d.desktop").unwrap();
301        let next_section = lines.iter().position(|l| *l == "[Added Associations]").unwrap();
302        assert!(section < added && added < next_section, "{lines:?}");
303        assert_eq!(lines[added + 1], "", "the blank line separating the sections survives");
304    }
305
306    #[test]
307    fn a_file_without_the_section_gains_one() {
308        let after = with_default("[Added Associations]\nmodel/3mf=fstl.desktop;\n", "image/png", "imv.desktop");
309        assert!(after.starts_with("[Added Associations]\nmodel/3mf=fstl.desktop;\n"));
310        assert!(after.ends_with("[Default Applications]\nimage/png=imv.desktop\n"), "{after}");
311    }
312
313    #[test]
314    fn an_empty_file_becomes_a_valid_one() {
315        assert_eq!(
316            with_default("", "image/png", "imv.desktop"),
317            "[Default Applications]\nimage/png=imv.desktop\n"
318        );
319        assert_eq!(parse(&with_default("", "image/png", "imv.desktop")).len(), 1);
320    }
321
322    /// A commented-out line is a note, not an entry: changing the
323    /// default must add a real line rather than editing someone's note.
324    #[test]
325    fn a_commented_out_entry_is_left_as_a_comment() {
326        let after = with_default("[Default Applications]\n#image/png=old.desktop\n", "image/png", "new.desktop");
327        assert!(after.contains("#image/png=old.desktop"));
328        assert!(after.contains("\nimage/png=new.desktop"));
329    }
330
331    /// Setting the same default twice is not two lines.
332    #[test]
333    fn setting_the_same_default_again_changes_nothing() {
334        let once = with_default(REAL, "image/png", "imv.desktop");
335        let twice = with_default(&once, "image/png", "imv.desktop");
336        assert_eq!(once, twice);
337    }
338}