use std::collections::BTreeMap;
const DEFAULTS_SECTION: &str = "[Default Applications]";
pub const ADDED_SECTION: &str = "[Added Associations]";
pub const REMOVED_SECTION: &str = "[Removed Associations]";
pub fn parse(text: &str) -> BTreeMap<String, Vec<String>> {
parse_section(text, DEFAULTS_SECTION)
}
pub fn parse_section(text: &str, section: &str) -> BTreeMap<String, Vec<String>> {
let mut defaults: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut in_section = false;
for line in text.lines() {
let line = line.trim();
if line.starts_with('[') {
in_section = line == section;
continue;
}
if !in_section || line.is_empty() || line.starts_with('#') {
continue;
}
let Some((mime, entries)) = line.split_once('=') else { continue };
let entries = entries.split(';').map(str::trim).filter(|e| !e.is_empty());
let known: &mut Vec<String> = defaults.entry(mime.trim().to_string()).or_default();
for entry in entries {
if !known.iter().any(|seen| seen == entry) {
known.push(entry.to_string());
}
}
}
defaults
}
pub fn with_default(text: &str, mime: &str, app: &str) -> String {
let new_line = format!("{mime}={app}");
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
let start = lines.iter().position(|line| line.trim() == DEFAULTS_SECTION);
let Some(start) = start else {
if !lines.is_empty() && !lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.push(String::new());
}
lines.push(DEFAULTS_SECTION.to_string());
lines.push(new_line);
return finish(lines);
};
let end = lines[start + 1..]
.iter()
.position(|line| line.trim_start().starts_with('['))
.map(|offset| start + 1 + offset)
.unwrap_or(lines.len());
let existing = lines[start + 1..end].iter().position(|line| {
line.split_once('=').is_some_and(|(key, _)| key.trim() == mime) && !line.trim_start().starts_with('#')
});
match existing {
Some(offset) => lines[start + 1 + offset] = new_line,
None => {
let last = lines[start + 1..end]
.iter()
.rposition(|line| !line.trim().is_empty())
.map(|offset| start + 2 + offset)
.unwrap_or(start + 1);
lines.insert(last, new_line);
}
}
finish(lines)
}
pub fn without_default(text: &str, mime: &str) -> Option<String> {
let lines: Vec<&str> = text.lines().collect();
let start = lines.iter().position(|line| line.trim() == DEFAULTS_SECTION)?;
let end = lines[start + 1..]
.iter()
.position(|line| line.trim_start().starts_with('['))
.map(|offset| start + 1 + offset)
.unwrap_or(lines.len());
let mut kept: Vec<String> = Vec::with_capacity(lines.len());
let mut removed = false;
for (index, line) in lines.iter().enumerate() {
let in_section = index > start && index < end;
let is_ours = in_section
&& !line.trim_start().starts_with('#')
&& line.split_once('=').is_some_and(|(key, _)| key.trim() == mime);
match is_ours {
true => removed = true,
false => kept.push(line.to_string()),
}
}
removed.then(|| finish(kept))
}
fn finish(lines: Vec<String>) -> String {
let mut text = lines.join("\n");
text.push('\n');
text
}
#[cfg(test)]
mod tests {
use super::*;
const REAL: &str = "\
# my associations
[Default Applications]
text/html=google-chrome.desktop
x-scheme-handler/http=google-chrome.desktop
model/stl=view3d.desktop
[Added Associations]
model/3mf=fstl.desktop;
";
#[test]
fn the_defaults_section_reads_as_type_to_applications() {
let defaults = parse(REAL);
assert_eq!(defaults.get("text/html").unwrap(), &["google-chrome.desktop"]);
assert_eq!(defaults.get("model/stl").unwrap(), &["view3d.desktop"]);
assert_eq!(defaults.get("model/3mf"), None, "that one is an association, not a default");
}
#[test]
fn the_other_two_sections_read_the_same_way() {
let added = parse_section(REAL, ADDED_SECTION);
assert_eq!(added.get("model/3mf").unwrap(), &["fstl.desktop"]);
assert!(parse_section(REAL, REMOVED_SECTION).is_empty(), "none in this file");
assert!(
!added.contains_key("text/html"),
"a default is not an association; the sections stay apart"
);
}
#[test]
fn clearing_removes_the_line_and_leaves_the_rest_alone() {
let out = without_default(REAL, "model/stl").expect("that type had a default");
assert!(!out.contains("model/stl"), "the line is gone");
assert!(out.contains("# my associations"), "and the comment above it is not");
assert!(out.contains("text/html=google-chrome.desktop"), "nor its neighbours");
assert!(out.contains("model/3mf=fstl.desktop;"), "nor the association in the next section");
}
#[test]
fn clearing_a_type_that_was_never_set_writes_nothing() {
assert_eq!(without_default(REAL, "image/png"), None);
assert_eq!(without_default("", "image/png"), None, "nor when there is no section at all");
assert_eq!(
without_default(REAL, "model/3mf"),
None,
"an added association is not a default, and clearing must not take it"
);
}
#[test]
fn clearing_takes_every_occurrence_of_the_type() {
let text = "[Default Applications]\nimage/png=a.desktop\nimage/gif=c.desktop\nimage/png=b.desktop\n";
let out = without_default(text, "image/png").expect("it was set");
assert!(!out.contains("image/png"), "both lines go: {out}");
assert!(out.contains("image/gif=c.desktop"));
assert!(!parse(&out).contains_key("image/png"));
}
#[test]
fn a_fallback_list_keeps_its_order() {
let defaults = parse("[Default Applications]\nimage/png=a.desktop;b.desktop;\n");
assert_eq!(defaults.get("image/png").unwrap(), &["a.desktop", "b.desktop"]);
}
#[test]
fn a_repeated_key_accumulates_rather_than_replacing() {
let defaults = parse(
"[Default Applications]\nimage/png=a.desktop\nimage/png=b.desktop;a.desktop\n",
);
assert_eq!(defaults.get("image/png").unwrap(), &["a.desktop", "b.desktop"]);
}
#[test]
fn the_registration_cache_reads_through_the_same_scanner() {
let cache = crate::apps::parse_cache(
"[MIME Cache]\nmodel/stl=viewer.desktop;slicer.desktop;\n",
);
assert_eq!(cache.get("model/stl").unwrap(), &["viewer.desktop", "slicer.desktop"]);
}
#[test]
fn changing_a_default_leaves_every_other_line_alone() {
let after = with_default(REAL, "model/stl", "BambuStudio.desktop");
assert!(after.contains("# my associations"), "the comment stays");
assert!(after.contains("[Added Associations]\nmodel/3mf=fstl.desktop;"), "so does a section we do not understand");
assert!(after.contains("model/stl=BambuStudio.desktop"));
assert!(!after.contains("model/stl=view3d.desktop"));
let lines: Vec<&str> = after.lines().collect();
assert_eq!(lines[4], "model/stl=BambuStudio.desktop");
}
#[test]
fn a_new_type_joins_the_section_rather_than_landing_after_it() {
let after = with_default(REAL, "model/3mf", "view3d.desktop");
let lines: Vec<&str> = after.lines().collect();
let section = lines.iter().position(|l| *l == "[Default Applications]").unwrap();
let added = lines.iter().position(|l| *l == "model/3mf=view3d.desktop").unwrap();
let next_section = lines.iter().position(|l| *l == "[Added Associations]").unwrap();
assert!(section < added && added < next_section, "{lines:?}");
assert_eq!(lines[added + 1], "", "the blank line separating the sections survives");
}
#[test]
fn a_file_without_the_section_gains_one() {
let after = with_default("[Added Associations]\nmodel/3mf=fstl.desktop;\n", "image/png", "imv.desktop");
assert!(after.starts_with("[Added Associations]\nmodel/3mf=fstl.desktop;\n"));
assert!(after.ends_with("[Default Applications]\nimage/png=imv.desktop\n"), "{after}");
}
#[test]
fn an_empty_file_becomes_a_valid_one() {
assert_eq!(
with_default("", "image/png", "imv.desktop"),
"[Default Applications]\nimage/png=imv.desktop\n"
);
assert_eq!(parse(&with_default("", "image/png", "imv.desktop")).len(), 1);
}
#[test]
fn a_commented_out_entry_is_left_as_a_comment() {
let after = with_default("[Default Applications]\n#image/png=old.desktop\n", "image/png", "new.desktop");
assert!(after.contains("#image/png=old.desktop"));
assert!(after.contains("\nimage/png=new.desktop"));
}
#[test]
fn setting_the_same_default_again_changes_nothing() {
let once = with_default(REAL, "image/png", "imv.desktop");
let twice = with_default(&once, "image/png", "imv.desktop");
assert_eq!(once, twice);
}
}