hyprforge_mime/
defaults.rs1use std::collections::BTreeMap;
24
25const DEFAULTS_SECTION: &str = "[Default Applications]";
26pub const ADDED_SECTION: &str = "[Added Associations]";
29pub const REMOVED_SECTION: &str = "[Removed Associations]";
32
33pub fn parse(text: &str) -> BTreeMap<String, Vec<String>> {
38 parse_section(text, DEFAULTS_SECTION)
39}
40
41pub 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
79pub 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 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 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
136pub 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
180fn 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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}