1use regex::Regex;
2use std::{
3 collections::HashMap,
4 path::{Path, PathBuf},
5 fs::File,
6 io::{BufRead, BufReader},
7};
8
9#[derive(Debug, Clone)]
10pub enum ParseError {
11 IoError(String),
12 InvalidFormat(String),
13 MissingRequiredKey(String),
14}
15
16#[derive(Debug, Clone, PartialEq)]
17pub enum ValueType {
18 String(String),
19 #[allow(dead_code)] LocaleString(String),
21 #[allow(dead_code)] IconString(String),
23 Boolean(bool),
24 Numeric(f64),
25 StringList(Vec<String>),
26 #[allow(dead_code)] LocaleStringList(Vec<String>),
28}
29
30#[derive(Debug, Clone)]
31pub struct LocalizedKey {
32 pub key: String,
33 pub locale: Option<String>,
34}
35
36impl LocalizedKey {
37 pub fn parse(input: &str) -> Self {
38 if let Some(bracket_start) = input.find('[') {
39 if let Some(bracket_end) = input.find(']') {
40 if bracket_start < bracket_end {
41 let key = input[..bracket_start].to_string();
42 let locale = input[bracket_start + 1..bracket_end].to_string();
43 return Self {
44 key,
45 locale: Some(locale),
46 };
47 }
48 }
49 }
50 Self {
51 key: input.to_string(),
52 locale: None,
53 }
54 }
55}
56
57#[derive(Debug, Default)]
58pub struct DesktopEntryGroup {
59 #[allow(dead_code)] pub name: String,
61 pub fields: HashMap<String, ValueType>,
62 pub localized_fields: HashMap<String, HashMap<String, ValueType>>,
63}
64
65impl DesktopEntryGroup {
66 pub fn new<S: Into<String>>(name: S) -> Self {
67 Self {
68 name: name.into(),
69 fields: HashMap::new(),
70 localized_fields: HashMap::new(),
71 }
72 }
73
74 pub fn insert_field(&mut self, key: &str, value: ValueType) {
75 let localized_key = LocalizedKey::parse(key);
76
77 if let Some(locale) = localized_key.locale {
78 self.localized_fields
79 .entry(localized_key.key)
80 .or_default()
81 .insert(locale, value);
82 } else {
83 self.fields.insert(localized_key.key, value);
84 }
85 }
86
87 pub fn get_field(&self, key: &str) -> Option<&ValueType> {
88 self.fields.get(key)
89 }
90
91 pub fn get_localized_field(&self, key: &str, locale: Option<&str>) -> Option<&ValueType> {
92 if let Some(locale) = locale {
93 if let Some(localized_map) = self.localized_fields.get(key) {
94 if let Some(value) = localized_map.get(locale) {
96 return Some(value);
97 }
98
99 if let Some(value) = self.try_locale_fallback(localized_map, locale) {
101 return Some(value);
102 }
103 }
104 }
105
106 self.fields.get(key)
108 }
109
110 fn try_locale_fallback<'a>(&self, localized_map: &'a HashMap<String, ValueType>, locale: &str) -> Option<&'a ValueType> {
111 let locale_without_encoding = if let Some(dot_pos) = locale.find('.') {
113 &locale[..dot_pos]
114 } else {
115 locale
116 };
117
118 let (lang, country, modifier) = Self::parse_locale_components(locale_without_encoding);
120
121 if let (Some(country), Some(modifier)) = (country, modifier) {
128 let full_locale = format!("{}_{}{}", lang, country, modifier);
130 if let Some(value) = localized_map.get(&full_locale) {
131 return Some(value);
132 }
133
134 let lang_country = format!("{}_{}", lang, country);
136 if let Some(value) = localized_map.get(&lang_country) {
137 return Some(value);
138 }
139
140 let lang_modifier = format!("{}{}", lang, modifier);
142 if let Some(value) = localized_map.get(&lang_modifier) {
143 return Some(value);
144 }
145 } else if let Some(country) = country {
146 let lang_country = format!("{}_{}", lang, country);
148 if let Some(value) = localized_map.get(&lang_country) {
149 return Some(value);
150 }
151 } else if let Some(modifier) = modifier {
152 let lang_modifier = format!("{}{}", lang, modifier);
154 if let Some(value) = localized_map.get(&lang_modifier) {
155 return Some(value);
156 }
157 }
158
159 localized_map.get(lang)
161 }
162
163 fn parse_locale_components(locale: &str) -> (&str, Option<&str>, Option<&str>) {
164 let (base, modifier) = if let Some(at_pos) = locale.find('@') {
165 (&locale[..at_pos], Some(&locale[at_pos..]))
166 } else {
167 (locale, None)
168 };
169
170 let (lang, country) = if let Some(under_pos) = base.find('_') {
171 (&base[..under_pos], Some(&base[under_pos + 1..]))
172 } else {
173 (base, None)
174 };
175
176 (lang, country, modifier)
177 }
178}
179
180#[derive(Debug, Default)]
181pub struct DesktopEntry {
182 pub path: PathBuf,
183 pub groups: HashMap<String, DesktopEntryGroup>,
184}
185
186impl DesktopEntry {
187 pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ParseError> {
188 let file = File::open(path.as_ref())
189 .map_err(|e| ParseError::IoError(format!("Failed to open file: {}", e)))?;
190 let reader = BufReader::new(file);
191
192 let group_header_regex = Regex::new(r"^\[([^\[\]]+)\]$")
193 .map_err(|e| ParseError::InvalidFormat(format!("Regex error: {}", e)))?;
194
195 let mut current_group: Option<String> = None;
196 let mut entry = DesktopEntry {
197 path: path.as_ref().to_path_buf(),
198 ..Default::default()
199 };
200
201 for (line_num, line) in reader.lines().enumerate() {
202 let line = line.map_err(|e| ParseError::IoError(format!("Failed to read line {}: {}", line_num + 1, e)))?;
203 let line = line.trim();
204
205 if line.is_empty() || line.starts_with('#') {
207 continue;
208 }
209
210 if let Some(captures) = group_header_regex.captures(line) {
212 let group_name = captures[1].to_string();
213 current_group = Some(group_name.clone());
214 entry.groups.entry(group_name.clone())
215 .or_insert_with(|| DesktopEntryGroup::new(group_name));
216 continue;
217 }
218
219 if let Some(eq_pos) = line.find('=') {
221 let key = line[..eq_pos].trim();
222 let value = line[eq_pos + 1..].trim();
223
224 if key.is_empty() {
225 continue; }
227
228 if !is_valid_key_name(key) {
229 return Err(ParseError::InvalidFormat(format!("Invalid key name: {}", key)));
230 }
231
232 if let Some(ref group_name) = current_group {
233 let parsed_value = parse_value(value)?;
234 if let Some(group) = entry.groups.get_mut(group_name) {
235 group.insert_field(key, parsed_value);
236 }
237 } else {
238 return Err(ParseError::InvalidFormat("Key-value pair found before any group header".to_string()));
239 }
240 }
241 }
242
243 entry.validate()?;
245
246 Ok(entry)
247 }
248
249 fn validate(&self) -> Result<(), ParseError> {
250 let desktop_entry = self.groups.get("Desktop Entry")
251 .ok_or_else(|| ParseError::MissingRequiredKey("Desktop Entry group is required".to_string()))?;
252
253 let entry_type = desktop_entry.get_field("Type")
255 .ok_or_else(|| ParseError::MissingRequiredKey("Type key is required".to_string()))?;
256
257 desktop_entry.get_field("Name")
259 .ok_or_else(|| ParseError::MissingRequiredKey("Name key is required".to_string()))?;
260
261 if let ValueType::String(type_val) = entry_type {
263 if type_val == "Application" {
264 let dbus_activatable = desktop_entry.get_field("DBusActivatable")
265 .and_then(|v| match v {
266 ValueType::Boolean(b) => Some(*b),
267 _ => None,
268 })
269 .unwrap_or(false);
270
271 if !dbus_activatable {
272 desktop_entry.get_field("Exec")
273 .ok_or_else(|| ParseError::MissingRequiredKey("Exec key is required for Application type".to_string()))?;
274 }
275 } else if type_val == "Link" {
276 desktop_entry.get_field("URL")
278 .ok_or_else(|| ParseError::MissingRequiredKey("URL key is required for Link type".to_string()))?;
279 }
280 }
281
282 Ok(())
283 }
284
285 pub fn get_desktop_entry_group(&self) -> Option<&DesktopEntryGroup> {
286 self.groups.get("Desktop Entry")
287 }
288}
289
290fn is_valid_key_name(key: &str) -> bool {
291 let base_key = if let Some(bracket_pos) = key.find('[') {
293 &key[..bracket_pos]
294 } else {
295 key
296 };
297
298 base_key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
300}
301
302fn parse_value(value: &str) -> Result<ValueType, ParseError> {
303 let unescaped = unescape_value(value);
305
306 match unescaped.to_lowercase().as_str() {
308 "true" => return Ok(ValueType::Boolean(true)),
309 "false" => return Ok(ValueType::Boolean(false)),
310 _ => {}
311 }
312
313 if let Ok(num) = unescaped.parse::<f64>() {
315 return Ok(ValueType::Numeric(num));
316 }
317
318 if value.contains(';') {
320 let items = split_semicolon_list(value);
321 return Ok(ValueType::StringList(items));
322 }
323
324 Ok(ValueType::String(unescaped))
326}
327
328fn unescape_value(value: &str) -> String {
329 let mut result = String::new();
330 let mut chars = value.chars();
331
332 while let Some(ch) = chars.next() {
333 if ch == '\\' {
334 if let Some(next_ch) = chars.next() {
335 match next_ch {
336 's' => result.push(' '),
337 'n' => result.push('\n'),
338 't' => result.push('\t'),
339 'r' => result.push('\r'),
340 '\\' => result.push('\\'),
341 ';' => result.push(';'), _ => {
343 result.push('\\');
345 result.push(next_ch);
346 }
347 }
348 } else {
349 result.push('\\');
350 }
351 } else {
352 result.push(ch);
353 }
354 }
355
356 result
357}
358
359fn split_semicolon_list(value: &str) -> Vec<String> {
360 let mut result = Vec::new();
361 let mut current_item = String::new();
362 let mut chars = value.chars().peekable();
363
364 while let Some(ch) = chars.next() {
365 if ch == '\\' {
366 if let Some(&next_ch) = chars.peek() {
367 if next_ch == ';' {
368 current_item.push(';');
370 chars.next(); } else {
372 current_item.push(ch);
374 if let Some(escaped_ch) = chars.next() {
375 current_item.push(escaped_ch);
376 }
377 }
378 } else {
379 current_item.push(ch);
380 }
381 } else if ch == ';' {
382 let trimmed = current_item.trim();
384 if !trimmed.is_empty() {
385 result.push(unescape_value(trimmed));
386 }
387 current_item.clear();
388 } else {
389 current_item.push(ch);
390 }
391 }
392
393 let trimmed = current_item.trim();
395 if !trimmed.is_empty() {
396 result.push(unescape_value(trimmed));
397 }
398
399 result
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn test_localized_key_parsing() {
408 let key = LocalizedKey::parse("Name");
409 assert_eq!(key.key, "Name");
410 assert_eq!(key.locale, None);
411
412 let key = LocalizedKey::parse("Name[en_US]");
413 assert_eq!(key.key, "Name");
414 assert_eq!(key.locale, Some("en_US".to_string()));
415 }
416
417 #[test]
418 fn test_value_parsing() {
419 assert_eq!(parse_value("true").unwrap(), ValueType::Boolean(true));
420 assert_eq!(parse_value("false").unwrap(), ValueType::Boolean(false));
421 assert_eq!(parse_value("123.45").unwrap(), ValueType::Numeric(123.45));
422 assert_eq!(parse_value("hello").unwrap(), ValueType::String("hello".to_string()));
423 assert_eq!(
424 parse_value("one;two;three").unwrap(),
425 ValueType::StringList(vec!["one".to_string(), "two".to_string(), "three".to_string()])
426 );
427 }
428
429 #[test]
430 fn test_escape_sequences() {
431 assert_eq!(unescape_value("hello\\sworld"), "hello world");
432 assert_eq!(unescape_value("line1\\nline2"), "line1\nline2");
433 assert_eq!(unescape_value("tab\\there"), "tab\there");
434 assert_eq!(unescape_value("backslash\\\\"), "backslash\\");
435 }
436
437 #[test]
438 fn test_key_validation() {
439 assert!(is_valid_key_name("Name"));
440 assert!(is_valid_key_name("Name[en_US]"));
441 assert!(is_valid_key_name("X-Custom-Key"));
442 assert!(!is_valid_key_name("Invalid Key"));
443 assert!(!is_valid_key_name("Key=Value"));
444 }
445}