desktop_edit/
desktop.rs

1//! Parser for INI/.desktop style files.
2//!
3//! This parser can be used to parse files in the INI/.desktop format (as specified
4//! by the [freedesktop.org Desktop Entry Specification](https://specifications.freedesktop.org/desktop-entry-spec/latest/)),
5//! while preserving all whitespace and comments. It is based on
6//! the [rowan] library, which is a lossless parser library for Rust.
7//!
8//! Once parsed, the file can be traversed or modified, and then written back to a file.
9//!
10//! # Example
11//!
12//! ```
13//! use desktop_edit::Desktop;
14//! use std::str::FromStr;
15//!
16//! # let input = r#"[Desktop Entry]
17//! # Name=Example Application
18//! # Type=Application
19//! # Exec=example
20//! # Icon=example.png
21//! # "#;
22//! # let desktop = Desktop::from_str(input).unwrap();
23//! # assert_eq!(desktop.groups().count(), 1);
24//! # let group = desktop.groups().nth(0).unwrap();
25//! # assert_eq!(group.name(), Some("Desktop Entry".to_string()));
26//! ```
27
28use crate::lex::{lex, SyntaxKind};
29use rowan::ast::AstNode;
30use rowan::{GreenNode, GreenNodeBuilder};
31use std::path::Path;
32use std::str::FromStr;
33
34/// A positioned parse error containing location information.
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct PositionedParseError {
37    /// The error message
38    pub message: String,
39    /// The text range where the error occurred
40    pub range: rowan::TextRange,
41    /// Optional error code for categorization
42    pub code: Option<String>,
43}
44
45impl std::fmt::Display for PositionedParseError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "{}", self.message)
48    }
49}
50
51impl std::error::Error for PositionedParseError {}
52
53/// List of encountered syntax errors.
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub struct ParseError(pub Vec<String>);
56
57impl std::fmt::Display for ParseError {
58    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
59        for err in &self.0 {
60            writeln!(f, "{}", err)?;
61        }
62        Ok(())
63    }
64}
65
66impl std::error::Error for ParseError {}
67
68/// Error parsing INI/.desktop files
69#[derive(Debug)]
70pub enum Error {
71    /// A syntax error was encountered while parsing the file.
72    ParseError(ParseError),
73
74    /// An I/O error was encountered while reading the file.
75    IoError(std::io::Error),
76}
77
78impl std::fmt::Display for Error {
79    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
80        match &self {
81            Error::ParseError(err) => write!(f, "{}", err),
82            Error::IoError(err) => write!(f, "{}", err),
83        }
84    }
85}
86
87impl From<ParseError> for Error {
88    fn from(err: ParseError) -> Self {
89        Self::ParseError(err)
90    }
91}
92
93impl From<std::io::Error> for Error {
94    fn from(err: std::io::Error) -> Self {
95        Self::IoError(err)
96    }
97}
98
99impl std::error::Error for Error {}
100
101/// Language definition for rowan
102#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
103pub enum Lang {}
104
105impl rowan::Language for Lang {
106    type Kind = SyntaxKind;
107
108    fn kind_from_raw(raw: rowan::SyntaxKind) -> Self::Kind {
109        unsafe { std::mem::transmute::<u16, SyntaxKind>(raw.0) }
110    }
111
112    fn kind_to_raw(kind: Self::Kind) -> rowan::SyntaxKind {
113        kind.into()
114    }
115}
116
117/// Internal parse result
118pub(crate) struct Parse {
119    pub(crate) green_node: GreenNode,
120    pub(crate) errors: Vec<String>,
121    pub(crate) positioned_errors: Vec<PositionedParseError>,
122}
123
124/// Parse an INI/.desktop file
125pub(crate) fn parse(text: &str) -> Parse {
126    struct Parser<'a> {
127        tokens: Vec<(SyntaxKind, &'a str)>,
128        builder: GreenNodeBuilder<'static>,
129        errors: Vec<String>,
130        positioned_errors: Vec<PositionedParseError>,
131        pos: usize,
132    }
133
134    impl<'a> Parser<'a> {
135        fn current(&self) -> Option<SyntaxKind> {
136            if self.pos < self.tokens.len() {
137                Some(self.tokens[self.tokens.len() - 1 - self.pos].0)
138            } else {
139                None
140            }
141        }
142
143        fn bump(&mut self) {
144            if self.pos < self.tokens.len() {
145                let (kind, text) = self.tokens[self.tokens.len() - 1 - self.pos];
146                self.builder.token(kind.into(), text);
147                self.pos += 1;
148            }
149        }
150
151        fn skip_ws(&mut self) {
152            while self.current() == Some(SyntaxKind::WHITESPACE) {
153                self.bump();
154            }
155        }
156
157        fn skip_blank_lines(&mut self) {
158            while let Some(kind) = self.current() {
159                match kind {
160                    SyntaxKind::NEWLINE => {
161                        self.builder.start_node(SyntaxKind::BLANK_LINE.into());
162                        self.bump();
163                        self.builder.finish_node();
164                    }
165                    SyntaxKind::WHITESPACE => {
166                        // Check if followed by newline
167                        if self.pos + 1 < self.tokens.len()
168                            && self.tokens[self.tokens.len() - 2 - self.pos].0
169                                == SyntaxKind::NEWLINE
170                        {
171                            self.builder.start_node(SyntaxKind::BLANK_LINE.into());
172                            self.bump(); // whitespace
173                            self.bump(); // newline
174                            self.builder.finish_node();
175                        } else {
176                            break;
177                        }
178                    }
179                    _ => break,
180                }
181            }
182        }
183
184        fn parse_group_header(&mut self) {
185            self.builder.start_node(SyntaxKind::GROUP_HEADER.into());
186
187            // Consume '['
188            if self.current() == Some(SyntaxKind::LEFT_BRACKET) {
189                self.bump();
190            } else {
191                self.errors
192                    .push("expected '[' at start of group header".to_string());
193            }
194
195            // Consume section name (stored as VALUE tokens)
196            if self.current() == Some(SyntaxKind::VALUE) {
197                self.bump();
198            } else {
199                self.errors
200                    .push("expected section name in group header".to_string());
201            }
202
203            // Consume ']'
204            if self.current() == Some(SyntaxKind::RIGHT_BRACKET) {
205                self.bump();
206            } else {
207                self.errors
208                    .push("expected ']' at end of group header".to_string());
209            }
210
211            // Consume newline if present
212            if self.current() == Some(SyntaxKind::NEWLINE) {
213                self.bump();
214            }
215
216            self.builder.finish_node();
217        }
218
219        fn parse_entry(&mut self) {
220            self.builder.start_node(SyntaxKind::ENTRY.into());
221
222            // Handle comment before entry
223            if self.current() == Some(SyntaxKind::COMMENT) {
224                self.bump();
225                if self.current() == Some(SyntaxKind::NEWLINE) {
226                    self.bump();
227                }
228                self.builder.finish_node();
229                return;
230            }
231
232            // Parse key
233            if self.current() == Some(SyntaxKind::KEY) {
234                self.bump();
235            } else {
236                self.errors
237                    .push(format!("expected key, got {:?}", self.current()));
238            }
239
240            self.skip_ws();
241
242            // Check for locale suffix [locale] - note that after KEY, we might get LEFT_BRACKET directly
243            // but the lexer treats [ as in_section_header mode, so we need to handle this differently
244            // Actually, we need to look for [ character in a key-value context
245            // For now, let's check if we have LEFT_BRACKET and handle it as locale
246            if self.current() == Some(SyntaxKind::LEFT_BRACKET) {
247                self.bump();
248                // After [, we should have the locale as VALUE (since lexer is in section header mode)
249                // But we need to handle this edge case
250                self.skip_ws();
251                if self.current() == Some(SyntaxKind::VALUE) {
252                    self.bump();
253                }
254                if self.current() == Some(SyntaxKind::RIGHT_BRACKET) {
255                    self.bump();
256                }
257                self.skip_ws();
258            }
259
260            // Parse '='
261            if self.current() == Some(SyntaxKind::EQUALS) {
262                self.bump();
263            } else {
264                self.errors.push("expected '=' after key".to_string());
265            }
266
267            self.skip_ws();
268
269            // Parse value
270            if self.current() == Some(SyntaxKind::VALUE) {
271                self.bump();
272            }
273
274            // Consume newline if present
275            if self.current() == Some(SyntaxKind::NEWLINE) {
276                self.bump();
277            }
278
279            self.builder.finish_node();
280        }
281
282        fn parse_group(&mut self) {
283            self.builder.start_node(SyntaxKind::GROUP.into());
284
285            // Parse group header
286            self.parse_group_header();
287
288            // Parse entries until we hit another group header or EOF
289            while let Some(kind) = self.current() {
290                match kind {
291                    SyntaxKind::LEFT_BRACKET => break, // Start of next group
292                    SyntaxKind::KEY | SyntaxKind::COMMENT => self.parse_entry(),
293                    SyntaxKind::NEWLINE | SyntaxKind::WHITESPACE => {
294                        self.skip_blank_lines();
295                    }
296                    _ => {
297                        self.errors
298                            .push(format!("unexpected token in group: {:?}", kind));
299                        self.bump();
300                    }
301                }
302            }
303
304            self.builder.finish_node();
305        }
306
307        fn parse_file(&mut self) {
308            self.builder.start_node(SyntaxKind::ROOT.into());
309
310            // Skip leading blank lines and comments
311            while let Some(kind) = self.current() {
312                match kind {
313                    SyntaxKind::COMMENT => {
314                        self.builder.start_node(SyntaxKind::ENTRY.into());
315                        self.bump();
316                        if self.current() == Some(SyntaxKind::NEWLINE) {
317                            self.bump();
318                        }
319                        self.builder.finish_node();
320                    }
321                    SyntaxKind::NEWLINE | SyntaxKind::WHITESPACE => {
322                        self.skip_blank_lines();
323                    }
324                    _ => break,
325                }
326            }
327
328            // Parse groups
329            while self.current().is_some() {
330                if self.current() == Some(SyntaxKind::LEFT_BRACKET) {
331                    self.parse_group();
332                } else {
333                    self.errors
334                        .push(format!("expected group header, got {:?}", self.current()));
335                    self.bump();
336                }
337            }
338
339            self.builder.finish_node();
340        }
341    }
342
343    let mut tokens: Vec<_> = lex(text).collect();
344    tokens.reverse();
345
346    let mut parser = Parser {
347        tokens,
348        builder: GreenNodeBuilder::new(),
349        errors: Vec::new(),
350        positioned_errors: Vec::new(),
351        pos: 0,
352    };
353
354    parser.parse_file();
355
356    Parse {
357        green_node: parser.builder.finish(),
358        errors: parser.errors,
359        positioned_errors: parser.positioned_errors,
360    }
361}
362
363// Type aliases for convenience
364type SyntaxNode = rowan::SyntaxNode<Lang>;
365
366/// The root of an INI/.desktop file
367#[derive(Debug, Clone, PartialEq, Eq, Hash)]
368pub struct Desktop(SyntaxNode);
369
370impl Desktop {
371    /// Get all groups in the file
372    pub fn groups(&self) -> impl Iterator<Item = Group> {
373        self.0.children().filter_map(Group::cast)
374    }
375
376    /// Get a specific group by name
377    pub fn get_group(&self, name: &str) -> Option<Group> {
378        self.groups().find(|g| g.name().as_deref() == Some(name))
379    }
380
381    /// Get the raw syntax node
382    pub fn syntax(&self) -> &SyntaxNode {
383        &self.0
384    }
385
386    /// Convert to a string (same as Display::fmt)
387    pub fn text(&self) -> String {
388        self.0.text().to_string()
389    }
390
391    /// Load from a file
392    pub fn from_file(path: &Path) -> Result<Self, Error> {
393        let text = std::fs::read_to_string(path)?;
394        Self::from_str(&text)
395    }
396}
397
398impl AstNode for Desktop {
399    type Language = Lang;
400
401    fn can_cast(kind: SyntaxKind) -> bool {
402        kind == SyntaxKind::ROOT
403    }
404
405    fn cast(node: SyntaxNode) -> Option<Self> {
406        if node.kind() == SyntaxKind::ROOT {
407            Some(Desktop(node))
408        } else {
409            None
410        }
411    }
412
413    fn syntax(&self) -> &SyntaxNode {
414        &self.0
415    }
416}
417
418impl FromStr for Desktop {
419    type Err = Error;
420
421    fn from_str(s: &str) -> Result<Self, Self::Err> {
422        let parsed = parse(s);
423        if !parsed.errors.is_empty() {
424            return Err(Error::ParseError(ParseError(parsed.errors)));
425        }
426        let node = SyntaxNode::new_root_mut(parsed.green_node);
427        Ok(Desktop::cast(node).expect("root node should be Desktop"))
428    }
429}
430
431impl std::fmt::Display for Desktop {
432    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433        write!(f, "{}", self.0.text())
434    }
435}
436
437/// A group/section in an INI/.desktop file (e.g., [Desktop Entry])
438#[derive(Debug, Clone, PartialEq, Eq, Hash)]
439pub struct Group(SyntaxNode);
440
441impl Group {
442    /// Get the name of the group
443    pub fn name(&self) -> Option<String> {
444        let header = self
445            .0
446            .children()
447            .find(|n| n.kind() == SyntaxKind::GROUP_HEADER)?;
448        let value = header
449            .children_with_tokens()
450            .find(|e| e.kind() == SyntaxKind::VALUE)?;
451        Some(value.as_token()?.text().to_string())
452    }
453
454    /// Get all entries in the group
455    pub fn entries(&self) -> impl Iterator<Item = Entry> {
456        self.0.children().filter_map(Entry::cast)
457    }
458
459    /// Get a specific entry by key
460    pub fn get(&self, key: &str) -> Option<String> {
461        self.entries()
462            .find(|e| e.key().as_deref() == Some(key) && e.locale().is_none())
463            .and_then(|e| e.value())
464    }
465
466    /// Get a localized value for a key (e.g., get_locale("Name", "de"))
467    pub fn get_locale(&self, key: &str, locale: &str) -> Option<String> {
468        self.entries()
469            .find(|e| e.key().as_deref() == Some(key) && e.locale().as_deref() == Some(locale))
470            .and_then(|e| e.value())
471    }
472
473    /// Get all locales for a given key
474    pub fn get_locales(&self, key: &str) -> Vec<String> {
475        self.entries()
476            .filter(|e| e.key().as_deref() == Some(key) && e.locale().is_some())
477            .filter_map(|e| e.locale())
478            .collect()
479    }
480
481    /// Get all entries for a key (including localized variants)
482    pub fn get_all(&self, key: &str) -> Vec<(Option<String>, String)> {
483        self.entries()
484            .filter(|e| e.key().as_deref() == Some(key))
485            .filter_map(|e| {
486                let value = e.value()?;
487                Some((e.locale(), value))
488            })
489            .collect()
490    }
491
492    /// Set a value for a key (or add if it doesn't exist)
493    pub fn set(&mut self, key: &str, value: &str) {
494        let new_entry = Entry::new(key, value);
495
496        // Check if the field already exists and replace it
497        for entry in self.entries() {
498            if entry.key().as_deref() == Some(key) && entry.locale().is_none() {
499                self.0.splice_children(
500                    entry.0.index()..entry.0.index() + 1,
501                    vec![new_entry.0.into()],
502                );
503                return;
504            }
505        }
506
507        // Field doesn't exist, append at the end (before the closing of the group)
508        let insertion_index = self.0.children_with_tokens().count();
509        self.0
510            .splice_children(insertion_index..insertion_index, vec![new_entry.0.into()]);
511    }
512
513    /// Set a localized value for a key (e.g., set_locale("Name", "de", "Beispiel"))
514    pub fn set_locale(&mut self, key: &str, locale: &str, value: &str) {
515        let new_entry = Entry::new_localized(key, locale, value);
516
517        // Check if the field already exists and replace it
518        for entry in self.entries() {
519            if entry.key().as_deref() == Some(key) && entry.locale().as_deref() == Some(locale) {
520                self.0.splice_children(
521                    entry.0.index()..entry.0.index() + 1,
522                    vec![new_entry.0.into()],
523                );
524                return;
525            }
526        }
527
528        // Field doesn't exist, append at the end (before the closing of the group)
529        let insertion_index = self.0.children_with_tokens().count();
530        self.0
531            .splice_children(insertion_index..insertion_index, vec![new_entry.0.into()]);
532    }
533
534    /// Remove an entry by key (non-localized only)
535    pub fn remove(&mut self, key: &str) {
536        // Find and remove the entry with the matching key (non-localized)
537        let entry_to_remove = self.0.children().find_map(|child| {
538            let entry = Entry::cast(child)?;
539            if entry.key().as_deref() == Some(key) && entry.locale().is_none() {
540                Some(entry)
541            } else {
542                None
543            }
544        });
545
546        if let Some(entry) = entry_to_remove {
547            entry.syntax().detach();
548        }
549    }
550
551    /// Remove a localized entry by key and locale
552    pub fn remove_locale(&mut self, key: &str, locale: &str) {
553        // Find and remove the entry with the matching key and locale
554        let entry_to_remove = self.0.children().find_map(|child| {
555            let entry = Entry::cast(child)?;
556            if entry.key().as_deref() == Some(key) && entry.locale().as_deref() == Some(locale) {
557                Some(entry)
558            } else {
559                None
560            }
561        });
562
563        if let Some(entry) = entry_to_remove {
564            entry.syntax().detach();
565        }
566    }
567
568    /// Remove all entries for a key (including all localized variants)
569    pub fn remove_all(&mut self, key: &str) {
570        // Collect all entries to remove first (can't mutate while iterating)
571        let entries_to_remove: Vec<_> = self
572            .0
573            .children()
574            .filter_map(Entry::cast)
575            .filter(|e| e.key().as_deref() == Some(key))
576            .collect();
577
578        for entry in entries_to_remove {
579            entry.syntax().detach();
580        }
581    }
582
583    /// Get the raw syntax node
584    pub fn syntax(&self) -> &SyntaxNode {
585        &self.0
586    }
587}
588
589impl AstNode for Group {
590    type Language = Lang;
591
592    fn can_cast(kind: SyntaxKind) -> bool {
593        kind == SyntaxKind::GROUP
594    }
595
596    fn cast(node: SyntaxNode) -> Option<Self> {
597        if node.kind() == SyntaxKind::GROUP {
598            Some(Group(node))
599        } else {
600            None
601        }
602    }
603
604    fn syntax(&self) -> &SyntaxNode {
605        &self.0
606    }
607}
608
609/// A key-value entry in a group
610#[derive(Debug, Clone, PartialEq, Eq, Hash)]
611pub struct Entry(SyntaxNode);
612
613impl Entry {
614    /// Create a new entry with key=value
615    pub fn new(key: &str, value: &str) -> Entry {
616        use rowan::GreenNodeBuilder;
617
618        let mut builder = GreenNodeBuilder::new();
619        builder.start_node(SyntaxKind::ENTRY.into());
620        builder.token(SyntaxKind::KEY.into(), key);
621        builder.token(SyntaxKind::EQUALS.into(), "=");
622        builder.token(SyntaxKind::VALUE.into(), value);
623        builder.token(SyntaxKind::NEWLINE.into(), "\n");
624        builder.finish_node();
625        Entry(SyntaxNode::new_root_mut(builder.finish()))
626    }
627
628    /// Create a new localized entry with key[locale]=value
629    pub fn new_localized(key: &str, locale: &str, value: &str) -> Entry {
630        use rowan::GreenNodeBuilder;
631
632        let mut builder = GreenNodeBuilder::new();
633        builder.start_node(SyntaxKind::ENTRY.into());
634        builder.token(SyntaxKind::KEY.into(), key);
635        builder.token(SyntaxKind::LEFT_BRACKET.into(), "[");
636        builder.token(SyntaxKind::VALUE.into(), locale);
637        builder.token(SyntaxKind::RIGHT_BRACKET.into(), "]");
638        builder.token(SyntaxKind::EQUALS.into(), "=");
639        builder.token(SyntaxKind::VALUE.into(), value);
640        builder.token(SyntaxKind::NEWLINE.into(), "\n");
641        builder.finish_node();
642        Entry(SyntaxNode::new_root_mut(builder.finish()))
643    }
644
645    /// Get the key name
646    pub fn key(&self) -> Option<String> {
647        let key_token = self
648            .0
649            .children_with_tokens()
650            .find(|e| e.kind() == SyntaxKind::KEY)?;
651        Some(key_token.as_token()?.text().to_string())
652    }
653
654    /// Get the value
655    pub fn value(&self) -> Option<String> {
656        // Find VALUE after EQUALS
657        let mut found_equals = false;
658        for element in self.0.children_with_tokens() {
659            match element.kind() {
660                SyntaxKind::EQUALS => found_equals = true,
661                SyntaxKind::VALUE if found_equals => {
662                    return Some(element.as_token()?.text().to_string());
663                }
664                _ => {}
665            }
666        }
667        None
668    }
669
670    /// Get the locale suffix if present (e.g., "de_DE" from "Name[de_DE]")
671    pub fn locale(&self) -> Option<String> {
672        // Find VALUE between [ and ] after KEY
673        let mut found_key = false;
674        let mut in_locale = false;
675        for element in self.0.children_with_tokens() {
676            match element.kind() {
677                SyntaxKind::KEY => found_key = true,
678                SyntaxKind::LEFT_BRACKET if found_key && !in_locale => in_locale = true,
679                SyntaxKind::VALUE if in_locale => {
680                    return Some(element.as_token()?.text().to_string());
681                }
682                SyntaxKind::RIGHT_BRACKET if in_locale => in_locale = false,
683                SyntaxKind::EQUALS => break, // Stop if we reach equals without finding locale
684                _ => {}
685            }
686        }
687        None
688    }
689
690    /// Get the raw syntax node
691    pub fn syntax(&self) -> &SyntaxNode {
692        &self.0
693    }
694}
695
696impl AstNode for Entry {
697    type Language = Lang;
698
699    fn can_cast(kind: SyntaxKind) -> bool {
700        kind == SyntaxKind::ENTRY
701    }
702
703    fn cast(node: SyntaxNode) -> Option<Self> {
704        if node.kind() == SyntaxKind::ENTRY {
705            Some(Entry(node))
706        } else {
707            None
708        }
709    }
710
711    fn syntax(&self) -> &SyntaxNode {
712        &self.0
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719
720    #[test]
721    fn test_parse_simple() {
722        let input = r#"[Desktop Entry]
723Name=Example
724Type=Application
725"#;
726        let desktop = Desktop::from_str(input).unwrap();
727        assert_eq!(desktop.groups().count(), 1);
728
729        let group = desktop.groups().nth(0).unwrap();
730        assert_eq!(group.name(), Some("Desktop Entry".to_string()));
731        assert_eq!(group.get("Name"), Some("Example".to_string()));
732        assert_eq!(group.get("Type"), Some("Application".to_string()));
733    }
734
735    #[test]
736    fn test_parse_with_comments() {
737        let input = r#"# Top comment
738[Desktop Entry]
739# Comment before name
740Name=Example
741Type=Application
742"#;
743        let desktop = Desktop::from_str(input).unwrap();
744        assert_eq!(desktop.groups().count(), 1);
745
746        let group = desktop.groups().nth(0).unwrap();
747        assert_eq!(group.get("Name"), Some("Example".to_string()));
748    }
749
750    #[test]
751    fn test_parse_multiple_groups() {
752        let input = r#"[Desktop Entry]
753Name=Example
754
755[Desktop Action Play]
756Name=Play
757Exec=example --play
758"#;
759        let desktop = Desktop::from_str(input).unwrap();
760        assert_eq!(desktop.groups().count(), 2);
761
762        let group1 = desktop.groups().nth(0).unwrap();
763        assert_eq!(group1.name(), Some("Desktop Entry".to_string()));
764
765        let group2 = desktop.groups().nth(1).unwrap();
766        assert_eq!(group2.name(), Some("Desktop Action Play".to_string()));
767        assert_eq!(group2.get("Name"), Some("Play".to_string()));
768    }
769
770    #[test]
771    fn test_parse_with_spaces() {
772        let input = "[Desktop Entry]\nName = Example Application\n";
773        let desktop = Desktop::from_str(input).unwrap();
774
775        let group = desktop.groups().nth(0).unwrap();
776        assert_eq!(group.get("Name"), Some("Example Application".to_string()));
777    }
778
779    #[test]
780    fn test_entry_locale() {
781        let input = "[Desktop Entry]\nName[de]=Beispiel\n";
782        let desktop = Desktop::from_str(input).unwrap();
783
784        let group = desktop.groups().nth(0).unwrap();
785        let entry = group.entries().nth(0).unwrap();
786        assert_eq!(entry.key(), Some("Name".to_string()));
787        assert_eq!(entry.locale(), Some("de".to_string()));
788        assert_eq!(entry.value(), Some("Beispiel".to_string()));
789    }
790
791    #[test]
792    fn test_lossless_roundtrip() {
793        let input = r#"# Comment
794[Desktop Entry]
795Name=Example
796Type=Application
797
798[Another Section]
799Key=Value
800"#;
801        let desktop = Desktop::from_str(input).unwrap();
802        let output = desktop.text();
803        assert_eq!(input, output);
804    }
805
806    #[test]
807    fn test_localized_query() {
808        let input = r#"[Desktop Entry]
809Name=Example Application
810Name[de]=Beispielanwendung
811Name[fr]=Application exemple
812Type=Application
813"#;
814        let desktop = Desktop::from_str(input).unwrap();
815        let group = desktop.groups().nth(0).unwrap();
816
817        // Test get() returns non-localized value
818        assert_eq!(group.get("Name"), Some("Example Application".to_string()));
819
820        // Test get_locale() returns localized values
821        assert_eq!(
822            group.get_locale("Name", "de"),
823            Some("Beispielanwendung".to_string())
824        );
825        assert_eq!(
826            group.get_locale("Name", "fr"),
827            Some("Application exemple".to_string())
828        );
829        assert_eq!(group.get_locale("Name", "es"), None);
830
831        // Test get_locales() returns all locales for a key
832        let locales = group.get_locales("Name");
833        assert_eq!(locales.len(), 2);
834        assert!(locales.contains(&"de".to_string()));
835        assert!(locales.contains(&"fr".to_string()));
836
837        // Test get_all() returns all variants
838        let all = group.get_all("Name");
839        assert_eq!(all.len(), 3);
840        assert!(all.contains(&(None, "Example Application".to_string())));
841        assert!(all.contains(&(Some("de".to_string()), "Beispielanwendung".to_string())));
842        assert!(all.contains(&(Some("fr".to_string()), "Application exemple".to_string())));
843    }
844
845    #[test]
846    fn test_localized_set() {
847        let input = r#"[Desktop Entry]
848Name=Example
849Name[de]=Beispiel
850Type=Application
851"#;
852        let desktop = Desktop::from_str(input).unwrap();
853        {
854            let mut group = desktop.groups().nth(0).unwrap();
855            // Update localized value
856            group.set_locale("Name", "de", "Neue Beispiel");
857        }
858
859        // Re-fetch the group to check the mutation persisted
860        let group = desktop.groups().nth(0).unwrap();
861        assert_eq!(
862            group.get_locale("Name", "de"),
863            Some("Neue Beispiel".to_string())
864        );
865
866        // Original value should remain unchanged
867        assert_eq!(group.get("Name"), Some("Example".to_string()));
868    }
869
870    #[test]
871    fn test_localized_remove() {
872        let input = r#"[Desktop Entry]
873Name=Example
874Name[de]=Beispiel
875Name[fr]=Exemple
876Type=Application
877"#;
878        let desktop = Desktop::from_str(input).unwrap();
879        let mut group = desktop.groups().nth(0).unwrap();
880
881        // Remove one localized entry
882        group.remove_locale("Name", "de");
883        assert_eq!(group.get_locale("Name", "de"), None);
884        assert_eq!(group.get_locale("Name", "fr"), Some("Exemple".to_string()));
885        assert_eq!(group.get("Name"), Some("Example".to_string()));
886
887        // Remove non-localized entry
888        group.remove("Name");
889        assert_eq!(group.get("Name"), None);
890        assert_eq!(group.get_locale("Name", "fr"), Some("Exemple".to_string()));
891    }
892
893    #[test]
894    fn test_localized_remove_all() {
895        let input = r#"[Desktop Entry]
896Name=Example
897Name[de]=Beispiel
898Name[fr]=Exemple
899Type=Application
900"#;
901        let desktop = Desktop::from_str(input).unwrap();
902        let mut group = desktop.groups().nth(0).unwrap();
903
904        // Remove all Name entries
905        group.remove_all("Name");
906        assert_eq!(group.get("Name"), None);
907        assert_eq!(group.get_locale("Name", "de"), None);
908        assert_eq!(group.get_locale("Name", "fr"), None);
909        assert_eq!(group.get_locales("Name").len(), 0);
910
911        // Type should still be there
912        assert_eq!(group.get("Type"), Some("Application".to_string()));
913    }
914
915    #[test]
916    fn test_get_distinguishes_localized() {
917        let input = r#"[Desktop Entry]
918Name[de]=Beispiel
919Type=Application
920"#;
921        let desktop = Desktop::from_str(input).unwrap();
922        let group = desktop.groups().nth(0).unwrap();
923
924        // get() should not return localized entries
925        assert_eq!(group.get("Name"), None);
926        assert_eq!(group.get_locale("Name", "de"), Some("Beispiel".to_string()));
927    }
928
929    #[test]
930    fn test_add_new_entry() {
931        let input = r#"[Desktop Entry]
932Name=Example
933"#;
934        let desktop = Desktop::from_str(input).unwrap();
935        {
936            let mut group = desktop.groups().nth(0).unwrap();
937            // Add a new entry
938            group.set("Type", "Application");
939        }
940
941        let group = desktop.groups().nth(0).unwrap();
942        assert_eq!(group.get("Name"), Some("Example".to_string()));
943        assert_eq!(group.get("Type"), Some("Application".to_string()));
944    }
945
946    #[test]
947    fn test_add_new_localized_entry() {
948        let input = r#"[Desktop Entry]
949Name=Example
950"#;
951        let desktop = Desktop::from_str(input).unwrap();
952        {
953            let mut group = desktop.groups().nth(0).unwrap();
954            // Add new localized entries
955            group.set_locale("Name", "de", "Beispiel");
956            group.set_locale("Name", "fr", "Exemple");
957        }
958
959        let group = desktop.groups().nth(0).unwrap();
960        assert_eq!(group.get("Name"), Some("Example".to_string()));
961        assert_eq!(group.get_locale("Name", "de"), Some("Beispiel".to_string()));
962        assert_eq!(group.get_locale("Name", "fr"), Some("Exemple".to_string()));
963        assert_eq!(group.get_locales("Name").len(), 2);
964    }
965}