Skip to main content

runlatch_core/
desktop_file.rs

1//! A small, order-preserving reader/writer for freedesktop Desktop Entry files.
2//!
3//! This is deliberately minimal: it models a `.desktop` file as a flat list of
4//! lines (group headers, `key=value` pairs, and raw comment/blank lines) so that
5//! [`set`](DesktopFile::set) and [`remove`](DesktopFile::remove) can edit a single
6//! key while leaving the rest of the file — comments, ordering, unrelated keys —
7//! byte-for-byte intact. That round-trip fidelity is what lets the XDG provider
8//! toggle `Hidden` without rewriting the whole entry.
9//!
10//! It does not implement the full Desktop Entry spec (no locale strings, no value
11//! escaping, no type coercion); it operates purely at the textual key/value level,
12//! which is all the providers in this crate need.
13
14/// The conventional main group of an autostart `.desktop` file.
15pub const DESKTOP_ENTRY_GROUP: &str = "Desktop Entry";
16
17/// One physical line of a desktop file.
18#[derive(Debug, Clone, PartialEq, Eq)]
19enum Line {
20    /// A `[Group Name]` header.
21    Group(String),
22    /// A `key=value` entry.
23    Pair { key: String, value: String },
24    /// Anything else: comments, blank lines, or unparsable content, preserved verbatim.
25    Raw(String),
26}
27
28/// An in-memory, order-preserving view of a `.desktop` file.
29#[derive(Debug, Clone, Default)]
30pub struct DesktopFile {
31    lines: Vec<Line>,
32}
33
34impl DesktopFile {
35    /// Parse desktop-file text into an editable, order-preserving model.
36    pub fn parse(text: &str) -> Self {
37        let mut lines = Vec::new();
38        for raw in text.lines() {
39            let trimmed = raw.trim();
40            if let Some(rest) = trimmed.strip_prefix('[')
41                && let Some(name) = rest.strip_suffix(']')
42            {
43                lines.push(Line::Group(name.to_string()));
44                continue;
45            }
46            // A key/value line: `key=value`. Comments (`#`) and blanks fall through
47            // to `Raw`. We only treat it as a pair when there's a non-empty key
48            // before the first `=` and the line isn't a comment.
49            if !trimmed.starts_with('#')
50                && let Some((key, value)) = raw.split_once('=')
51            {
52                let key_trimmed = key.trim();
53                if !key_trimmed.is_empty() && !key_trimmed.contains(char::is_whitespace) {
54                    lines.push(Line::Pair {
55                        key: key_trimmed.to_string(),
56                        value: value.to_string(),
57                    });
58                    continue;
59                }
60            }
61            lines.push(Line::Raw(raw.to_string()));
62        }
63        Self { lines }
64    }
65
66    /// Serialize back to desktop-file text (always newline-terminated).
67    pub fn to_text(&self) -> String {
68        let mut out = String::new();
69        for line in &self.lines {
70            match line {
71                Line::Group(name) => {
72                    out.push('[');
73                    out.push_str(name);
74                    out.push(']');
75                }
76                Line::Pair { key, value } => {
77                    out.push_str(key);
78                    out.push('=');
79                    out.push_str(value);
80                }
81                Line::Raw(raw) => out.push_str(raw),
82            }
83            out.push('\n');
84        }
85        out
86    }
87
88    /// Get the value of `key` within `group`, if present.
89    pub fn get(&self, group: &str, key: &str) -> Option<&str> {
90        let (start, end) = self.group_range(group)?;
91        self.lines[start..end].iter().find_map(|line| match line {
92            Line::Pair { key: k, value } if k == key => Some(value.as_str()),
93            _ => None,
94        })
95    }
96
97    /// Set `key=value` within `group`, updating in place if the key exists,
98    /// appending to the end of the group otherwise. The group is created at the end
99    /// of the file if it does not yet exist.
100    pub fn set(&mut self, group: &str, key: &str, value: &str) {
101        let Some((start, end)) = self.group_range(group) else {
102            // No such group: create it, then the key.
103            self.lines.push(Line::Group(group.to_string()));
104            self.lines.push(Line::Pair {
105                key: key.to_string(),
106                value: value.to_string(),
107            });
108            return;
109        };
110
111        for line in &mut self.lines[start..end] {
112            if let Line::Pair { key: k, value: v } = line
113                && k == key
114            {
115                *v = value.to_string();
116                return;
117            }
118        }
119
120        // Key not found in the group: insert just after the last non-blank line of
121        // the group so it stays grouped, rather than after trailing blank lines.
122        let insert_at = self.lines[start..end]
123            .iter()
124            .rposition(|l| !matches!(l, Line::Raw(r) if r.trim().is_empty()))
125            .map(|rel| start + rel + 1)
126            .unwrap_or(end);
127        self.lines.insert(
128            insert_at,
129            Line::Pair {
130                key: key.to_string(),
131                value: value.to_string(),
132            },
133        );
134    }
135
136    /// Remove `key` from `group`. Returns `true` if a key was removed.
137    pub fn remove(&mut self, group: &str, key: &str) -> bool {
138        let Some((start, end)) = self.group_range(group) else {
139            return false;
140        };
141        if let Some(rel) = self.lines[start..end]
142            .iter()
143            .position(|line| matches!(line, Line::Pair { key: k, .. } if k == key))
144        {
145            self.lines.remove(start + rel);
146            true
147        } else {
148            false
149        }
150    }
151
152    /// Find the half-open line range `[start, end)` covering the body of `group`,
153    /// where `start` is the line just after the group header and `end` is the next
154    /// group header (or end of file).
155    fn group_range(&self, group: &str) -> Option<(usize, usize)> {
156        let header = self
157            .lines
158            .iter()
159            .position(|l| matches!(l, Line::Group(g) if g == group))?;
160        let start = header + 1;
161        let end = self.lines[start..]
162            .iter()
163            .position(|l| matches!(l, Line::Group(_)))
164            .map(|rel| start + rel)
165            .unwrap_or(self.lines.len());
166        Some((start, end))
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    const SAMPLE: &str = "\
175[Desktop Entry]
176Type=Application
177Name=Example
178# a comment
179Exec=example --flag
180Icon=example
181";
182
183    #[test]
184    fn parses_and_round_trips() {
185        let f = DesktopFile::parse(SAMPLE);
186        assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Name"), Some("Example"));
187        assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Exec"), Some("example --flag"));
188        assert_eq!(f.to_text(), SAMPLE);
189    }
190
191    #[test]
192    fn set_preserves_unrelated_keys_and_order() {
193        let mut f = DesktopFile::parse(SAMPLE);
194        f.set(DESKTOP_ENTRY_GROUP, "Hidden", "true");
195        let out = f.to_text();
196        // The new key is appended within the group...
197        assert!(out.contains("Hidden=true"));
198        // ...and the comment and other keys survive in order.
199        assert!(out.contains("# a comment"));
200        assert!(out.contains("Name=Example"));
201        assert!(out.find("Type=Application").unwrap() < out.find("Hidden=true").unwrap());
202    }
203
204    #[test]
205    fn set_updates_existing_key_in_place() {
206        let mut f = DesktopFile::parse(SAMPLE);
207        f.set(DESKTOP_ENTRY_GROUP, "Name", "Renamed");
208        assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Name"), Some("Renamed"));
209        // No duplicate key was introduced.
210        assert_eq!(f.to_text().matches("Name=").count(), 1);
211    }
212
213    #[test]
214    fn remove_drops_only_target_key() {
215        let mut f = DesktopFile::parse(SAMPLE);
216        assert!(f.remove(DESKTOP_ENTRY_GROUP, "Icon"));
217        assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Icon"), None);
218        assert!(f.get(DESKTOP_ENTRY_GROUP, "Name").is_some());
219        // Removing a missing key is a no-op returning false.
220        assert!(!f.remove(DESKTOP_ENTRY_GROUP, "Nonexistent"));
221    }
222
223    #[test]
224    fn set_creates_missing_group() {
225        let mut f = DesktopFile::parse("");
226        f.set(DESKTOP_ENTRY_GROUP, "Type", "Application");
227        assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Type"), Some("Application"));
228    }
229}