pub const DESKTOP_ENTRY_GROUP: &str = "Desktop Entry";
#[derive(Debug, Clone, PartialEq, Eq)]
enum Line {
Group(String),
Pair { key: String, value: String },
Raw(String),
}
#[derive(Debug, Clone, Default)]
pub struct DesktopFile {
lines: Vec<Line>,
}
impl DesktopFile {
pub fn parse(text: &str) -> Self {
let mut lines = Vec::new();
for raw in text.lines() {
let trimmed = raw.trim();
if let Some(rest) = trimmed.strip_prefix('[')
&& let Some(name) = rest.strip_suffix(']')
{
lines.push(Line::Group(name.to_string()));
continue;
}
if !trimmed.starts_with('#')
&& let Some((key, value)) = raw.split_once('=')
{
let key_trimmed = key.trim();
if !key_trimmed.is_empty() && !key_trimmed.contains(char::is_whitespace) {
lines.push(Line::Pair {
key: key_trimmed.to_string(),
value: value.to_string(),
});
continue;
}
}
lines.push(Line::Raw(raw.to_string()));
}
Self { lines }
}
pub fn to_text(&self) -> String {
let mut out = String::new();
for line in &self.lines {
match line {
Line::Group(name) => {
out.push('[');
out.push_str(name);
out.push(']');
}
Line::Pair { key, value } => {
out.push_str(key);
out.push('=');
out.push_str(value);
}
Line::Raw(raw) => out.push_str(raw),
}
out.push('\n');
}
out
}
pub fn get(&self, group: &str, key: &str) -> Option<&str> {
let (start, end) = self.group_range(group)?;
self.lines[start..end].iter().find_map(|line| match line {
Line::Pair { key: k, value } if k == key => Some(value.as_str()),
_ => None,
})
}
pub fn set(&mut self, group: &str, key: &str, value: &str) {
let Some((start, end)) = self.group_range(group) else {
self.lines.push(Line::Group(group.to_string()));
self.lines.push(Line::Pair {
key: key.to_string(),
value: value.to_string(),
});
return;
};
for line in &mut self.lines[start..end] {
if let Line::Pair { key: k, value: v } = line
&& k == key
{
*v = value.to_string();
return;
}
}
let insert_at = self.lines[start..end]
.iter()
.rposition(|l| !matches!(l, Line::Raw(r) if r.trim().is_empty()))
.map(|rel| start + rel + 1)
.unwrap_or(end);
self.lines.insert(
insert_at,
Line::Pair {
key: key.to_string(),
value: value.to_string(),
},
);
}
pub fn remove(&mut self, group: &str, key: &str) -> bool {
let Some((start, end)) = self.group_range(group) else {
return false;
};
if let Some(rel) = self.lines[start..end]
.iter()
.position(|line| matches!(line, Line::Pair { key: k, .. } if k == key))
{
self.lines.remove(start + rel);
true
} else {
false
}
}
fn group_range(&self, group: &str) -> Option<(usize, usize)> {
let header = self
.lines
.iter()
.position(|l| matches!(l, Line::Group(g) if g == group))?;
let start = header + 1;
let end = self.lines[start..]
.iter()
.position(|l| matches!(l, Line::Group(_)))
.map(|rel| start + rel)
.unwrap_or(self.lines.len());
Some((start, end))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
[Desktop Entry]
Type=Application
Name=Example
# a comment
Exec=example --flag
Icon=example
";
#[test]
fn parses_and_round_trips() {
let f = DesktopFile::parse(SAMPLE);
assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Name"), Some("Example"));
assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Exec"), Some("example --flag"));
assert_eq!(f.to_text(), SAMPLE);
}
#[test]
fn set_preserves_unrelated_keys_and_order() {
let mut f = DesktopFile::parse(SAMPLE);
f.set(DESKTOP_ENTRY_GROUP, "Hidden", "true");
let out = f.to_text();
assert!(out.contains("Hidden=true"));
assert!(out.contains("# a comment"));
assert!(out.contains("Name=Example"));
assert!(out.find("Type=Application").unwrap() < out.find("Hidden=true").unwrap());
}
#[test]
fn set_updates_existing_key_in_place() {
let mut f = DesktopFile::parse(SAMPLE);
f.set(DESKTOP_ENTRY_GROUP, "Name", "Renamed");
assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Name"), Some("Renamed"));
assert_eq!(f.to_text().matches("Name=").count(), 1);
}
#[test]
fn remove_drops_only_target_key() {
let mut f = DesktopFile::parse(SAMPLE);
assert!(f.remove(DESKTOP_ENTRY_GROUP, "Icon"));
assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Icon"), None);
assert!(f.get(DESKTOP_ENTRY_GROUP, "Name").is_some());
assert!(!f.remove(DESKTOP_ENTRY_GROUP, "Nonexistent"));
}
#[test]
fn set_creates_missing_group() {
let mut f = DesktopFile::parse("");
f.set(DESKTOP_ENTRY_GROUP, "Type", "Application");
assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Type"), Some("Application"));
}
}