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)]
30pub struct DesktopFile {
31 lines: Vec<Line>,
32}
33
34impl DesktopFile {
35 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 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 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 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 pub fn set(&mut self, group: &str, key: &str, value: &str) {
101 let Some((start, end)) = self.group_range(group) else {
102 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 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 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 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 assert!(out.contains("Hidden=true"));
198 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 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 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}