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///
30/// ```
31/// use runlatch_core::desktop_file::{DesktopFile, DESKTOP_ENTRY_GROUP};
32///
33/// let mut file = DesktopFile::parse("[Desktop Entry]\nName=Example\nExec=example\n");
34/// assert_eq!(file.get(DESKTOP_ENTRY_GROUP, "Name"), Some("Example"));
35///
36/// // Editing one key leaves everything else byte-for-byte intact.
37/// file.set(DESKTOP_ENTRY_GROUP, "Hidden", "true");
38/// assert_eq!(
39///     file.to_text(),
40///     "[Desktop Entry]\nName=Example\nExec=example\nHidden=true\n",
41/// );
42/// ```
43#[derive(Debug, Clone, Default)]
44pub struct DesktopFile {
45    lines: Vec<Line>,
46}
47
48impl DesktopFile {
49    /// Parse desktop-file text into an editable, order-preserving model.
50    pub fn parse(text: &str) -> Self {
51        let mut lines = Vec::new();
52        for raw in text.lines() {
53            let trimmed = raw.trim();
54            if let Some(rest) = trimmed.strip_prefix('[')
55                && let Some(name) = rest.strip_suffix(']')
56            {
57                lines.push(Line::Group(name.to_string()));
58                continue;
59            }
60            // A key/value line: `key=value`. Comments (`#`) and blanks fall through
61            // to `Raw`. We only treat it as a pair when there's a non-empty key
62            // before the first `=` and the line isn't a comment.
63            if !trimmed.starts_with('#')
64                && let Some((key, value)) = raw.split_once('=')
65            {
66                let key_trimmed = key.trim();
67                if !key_trimmed.is_empty() && !key_trimmed.contains(char::is_whitespace) {
68                    lines.push(Line::Pair {
69                        key: key_trimmed.to_string(),
70                        value: value.to_string(),
71                    });
72                    continue;
73                }
74            }
75            lines.push(Line::Raw(raw.to_string()));
76        }
77        Self { lines }
78    }
79
80    /// Serialize back to desktop-file text (always newline-terminated).
81    pub fn to_text(&self) -> String {
82        let mut out = String::new();
83        for line in &self.lines {
84            match line {
85                Line::Group(name) => {
86                    out.push('[');
87                    out.push_str(name);
88                    out.push(']');
89                }
90                Line::Pair { key, value } => {
91                    out.push_str(key);
92                    out.push('=');
93                    out.push_str(value);
94                }
95                Line::Raw(raw) => out.push_str(raw),
96            }
97            out.push('\n');
98        }
99        out
100    }
101
102    /// Get the value of `key` within `group`, if present.
103    pub fn get(&self, group: &str, key: &str) -> Option<&str> {
104        let (start, end) = self.group_range(group)?;
105        self.lines[start..end].iter().find_map(|line| match line {
106            Line::Pair { key: k, value } if k == key => Some(value.as_str()),
107            _ => None,
108        })
109    }
110
111    /// Set `key=value` within `group`, updating in place if the key exists,
112    /// appending to the end of the group otherwise. The group is created at the end
113    /// of the file if it does not yet exist.
114    pub fn set(&mut self, group: &str, key: &str, value: &str) {
115        let Some((start, end)) = self.group_range(group) else {
116            // No such group: create it, then the key.
117            self.lines.push(Line::Group(group.to_string()));
118            self.lines.push(Line::Pair {
119                key: key.to_string(),
120                value: value.to_string(),
121            });
122            return;
123        };
124
125        for line in &mut self.lines[start..end] {
126            if let Line::Pair { key: k, value: v } = line
127                && k == key
128            {
129                *v = value.to_string();
130                return;
131            }
132        }
133
134        // Key not found in the group: insert just after the last non-blank line of
135        // the group so it stays grouped, rather than after trailing blank lines.
136        let insert_at = self.lines[start..end]
137            .iter()
138            .rposition(|l| !matches!(l, Line::Raw(r) if r.trim().is_empty()))
139            .map(|rel| start + rel + 1)
140            .unwrap_or(end);
141        self.lines.insert(
142            insert_at,
143            Line::Pair {
144                key: key.to_string(),
145                value: value.to_string(),
146            },
147        );
148    }
149
150    /// Remove `key` from `group`. Returns `true` if a key was removed.
151    pub fn remove(&mut self, group: &str, key: &str) -> bool {
152        let Some((start, end)) = self.group_range(group) else {
153            return false;
154        };
155        if let Some(rel) = self.lines[start..end]
156            .iter()
157            .position(|line| matches!(line, Line::Pair { key: k, .. } if k == key))
158        {
159            self.lines.remove(start + rel);
160            true
161        } else {
162            false
163        }
164    }
165
166    /// Find the half-open line range `[start, end)` covering the body of `group`,
167    /// where `start` is the line just after the group header and `end` is the next
168    /// group header (or end of file).
169    fn group_range(&self, group: &str) -> Option<(usize, usize)> {
170        let header = self
171            .lines
172            .iter()
173            .position(|l| matches!(l, Line::Group(g) if g == group))?;
174        let start = header + 1;
175        let end = self.lines[start..]
176            .iter()
177            .position(|l| matches!(l, Line::Group(_)))
178            .map(|rel| start + rel)
179            .unwrap_or(self.lines.len());
180        Some((start, end))
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    const SAMPLE: &str = "\
189[Desktop Entry]
190Type=Application
191Name=Example
192# a comment
193Exec=example --flag
194Icon=example
195";
196
197    #[test]
198    fn parses_and_round_trips() {
199        let f = DesktopFile::parse(SAMPLE);
200        assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Name"), Some("Example"));
201        assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Exec"), Some("example --flag"));
202        assert_eq!(f.to_text(), SAMPLE);
203    }
204
205    #[test]
206    fn set_preserves_unrelated_keys_and_order() {
207        let mut f = DesktopFile::parse(SAMPLE);
208        f.set(DESKTOP_ENTRY_GROUP, "Hidden", "true");
209        let out = f.to_text();
210        // The new key is appended within the group...
211        assert!(out.contains("Hidden=true"));
212        // ...and the comment and other keys survive in order.
213        assert!(out.contains("# a comment"));
214        assert!(out.contains("Name=Example"));
215        assert!(out.find("Type=Application").unwrap() < out.find("Hidden=true").unwrap());
216    }
217
218    #[test]
219    fn set_updates_existing_key_in_place() {
220        let mut f = DesktopFile::parse(SAMPLE);
221        f.set(DESKTOP_ENTRY_GROUP, "Name", "Renamed");
222        assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Name"), Some("Renamed"));
223        // No duplicate key was introduced.
224        assert_eq!(f.to_text().matches("Name=").count(), 1);
225    }
226
227    #[test]
228    fn remove_drops_only_target_key() {
229        let mut f = DesktopFile::parse(SAMPLE);
230        assert!(f.remove(DESKTOP_ENTRY_GROUP, "Icon"));
231        assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Icon"), None);
232        assert!(f.get(DESKTOP_ENTRY_GROUP, "Name").is_some());
233        // Removing a missing key is a no-op returning false.
234        assert!(!f.remove(DESKTOP_ENTRY_GROUP, "Nonexistent"));
235    }
236
237    #[test]
238    fn set_creates_missing_group() {
239        let mut f = DesktopFile::parse("");
240        f.set(DESKTOP_ENTRY_GROUP, "Type", "Application");
241        assert_eq!(f.get(DESKTOP_ENTRY_GROUP, "Type"), Some("Application"));
242    }
243}