runlatch_core/
desktop_file.rs1pub const DESKTOP_ENTRY_GROUP: &str = "Desktop Entry";
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19enum Line {
20 Group(String),
22 Pair { key: String, value: String },
24 Raw(String),
26}
27
28#[derive(Debug, Clone, Default)]
44pub struct DesktopFile {
45 lines: Vec<Line>,
46}
47
48impl DesktopFile {
49 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 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 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 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 pub fn set(&mut self, group: &str, key: &str, value: &str) {
115 let Some((start, end)) = self.group_range(group) else {
116 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 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 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 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 assert!(out.contains("Hidden=true"));
212 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 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 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}