systemd_unit_edit/
unit.rs

1//! Parser for systemd unit files.
2//!
3//! This parser can be used to parse systemd unit files (as specified
4//! by the [systemd.syntax(7)](https://www.freedesktop.org/software/systemd/man/latest/systemd.syntax.html)),
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 systemd_unit_edit::SystemdUnit;
14//! use std::str::FromStr;
15//!
16//! # let input = r#"[Unit]
17//! # Description=Test Service
18//! # After=network.target
19//! #
20//! # [Service]
21//! # Type=simple
22//! # ExecStart=/usr/bin/test
23//! # "#;
24//! # let unit = SystemdUnit::from_str(input).unwrap();
25//! # assert_eq!(unit.sections().count(), 2);
26//! # let section = unit.sections().next().unwrap();
27//! # assert_eq!(section.name(), Some("Unit".to_string()));
28//! ```
29
30use crate::lex::{lex, SyntaxKind};
31use rowan::ast::AstNode;
32use rowan::{GreenNode, GreenNodeBuilder};
33use std::path::Path;
34use std::str::FromStr;
35
36/// A positioned parse error containing location information.
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
38pub struct PositionedParseError {
39    /// The error message
40    pub message: String,
41    /// The text range where the error occurred
42    pub range: rowan::TextRange,
43    /// Optional error code for categorization
44    pub code: Option<String>,
45}
46
47impl std::fmt::Display for PositionedParseError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(f, "{}", self.message)
50    }
51}
52
53impl std::error::Error for PositionedParseError {}
54
55/// List of encountered syntax errors.
56#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57pub struct ParseError(pub Vec<String>);
58
59impl std::fmt::Display for ParseError {
60    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
61        for err in &self.0 {
62            writeln!(f, "{}", err)?;
63        }
64        Ok(())
65    }
66}
67
68impl std::error::Error for ParseError {}
69
70/// Error parsing systemd unit files
71#[derive(Debug)]
72pub enum Error {
73    /// A syntax error was encountered while parsing the file.
74    ParseError(ParseError),
75
76    /// An I/O error was encountered while reading the file.
77    IoError(std::io::Error),
78}
79
80impl std::fmt::Display for Error {
81    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
82        match &self {
83            Error::ParseError(err) => write!(f, "{}", err),
84            Error::IoError(err) => write!(f, "{}", err),
85        }
86    }
87}
88
89impl From<ParseError> for Error {
90    fn from(err: ParseError) -> Self {
91        Self::ParseError(err)
92    }
93}
94
95impl From<std::io::Error> for Error {
96    fn from(err: std::io::Error) -> Self {
97        Self::IoError(err)
98    }
99}
100
101impl std::error::Error for Error {}
102
103/// Language definition for rowan
104#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
105pub enum Lang {}
106
107impl rowan::Language for Lang {
108    type Kind = SyntaxKind;
109
110    fn kind_from_raw(raw: rowan::SyntaxKind) -> Self::Kind {
111        unsafe { std::mem::transmute::<u16, SyntaxKind>(raw.0) }
112    }
113
114    fn kind_to_raw(kind: Self::Kind) -> rowan::SyntaxKind {
115        kind.into()
116    }
117}
118
119/// Internal parse result
120pub(crate) struct ParseResult {
121    pub(crate) green_node: GreenNode,
122    pub(crate) errors: Vec<String>,
123    pub(crate) positioned_errors: Vec<PositionedParseError>,
124}
125
126/// Parse a systemd unit file
127pub(crate) fn parse(text: &str) -> ParseResult {
128    struct Parser<'a> {
129        tokens: Vec<(SyntaxKind, &'a str)>,
130        builder: GreenNodeBuilder<'static>,
131        errors: Vec<String>,
132        positioned_errors: Vec<PositionedParseError>,
133        pos: usize,
134    }
135
136    impl<'a> Parser<'a> {
137        fn current(&self) -> Option<SyntaxKind> {
138            if self.pos < self.tokens.len() {
139                Some(self.tokens[self.tokens.len() - 1 - self.pos].0)
140            } else {
141                None
142            }
143        }
144
145        fn bump(&mut self) {
146            if self.pos < self.tokens.len() {
147                let (kind, text) = self.tokens[self.tokens.len() - 1 - self.pos];
148                self.builder.token(kind.into(), text);
149                self.pos += 1;
150            }
151        }
152
153        fn skip_ws(&mut self) {
154            while self.current() == Some(SyntaxKind::WHITESPACE) {
155                self.bump();
156            }
157        }
158
159        fn skip_blank_lines(&mut self) {
160            while let Some(kind) = self.current() {
161                match kind {
162                    SyntaxKind::NEWLINE => {
163                        self.builder.start_node(SyntaxKind::BLANK_LINE.into());
164                        self.bump();
165                        self.builder.finish_node();
166                    }
167                    SyntaxKind::WHITESPACE => {
168                        // Check if followed by newline
169                        if self.pos + 1 < self.tokens.len()
170                            && self.tokens[self.tokens.len() - 2 - self.pos].0
171                                == SyntaxKind::NEWLINE
172                        {
173                            self.builder.start_node(SyntaxKind::BLANK_LINE.into());
174                            self.bump(); // whitespace
175                            self.bump(); // newline
176                            self.builder.finish_node();
177                        } else {
178                            break;
179                        }
180                    }
181                    _ => break,
182                }
183            }
184        }
185
186        fn parse_section_header(&mut self) {
187            self.builder.start_node(SyntaxKind::SECTION_HEADER.into());
188
189            // Consume '['
190            if self.current() == Some(SyntaxKind::LEFT_BRACKET) {
191                self.bump();
192            } else {
193                self.errors
194                    .push("expected '[' at start of section header".to_string());
195            }
196
197            // Consume section name
198            if self.current() == Some(SyntaxKind::SECTION_NAME) {
199                self.bump();
200            } else {
201                self.errors
202                    .push("expected section name in section header".to_string());
203            }
204
205            // Consume ']'
206            if self.current() == Some(SyntaxKind::RIGHT_BRACKET) {
207                self.bump();
208            } else {
209                self.errors
210                    .push("expected ']' at end of section header".to_string());
211            }
212
213            // Consume newline if present
214            if self.current() == Some(SyntaxKind::NEWLINE) {
215                self.bump();
216            }
217
218            self.builder.finish_node();
219        }
220
221        fn parse_entry(&mut self) {
222            self.builder.start_node(SyntaxKind::ENTRY.into());
223
224            // Handle comment before entry
225            if self.current() == Some(SyntaxKind::COMMENT) {
226                self.bump();
227                if self.current() == Some(SyntaxKind::NEWLINE) {
228                    self.bump();
229                }
230                self.builder.finish_node();
231                return;
232            }
233
234            // Parse key
235            if self.current() == Some(SyntaxKind::KEY) {
236                self.bump();
237            } else {
238                self.errors
239                    .push(format!("expected key, got {:?}", self.current()));
240            }
241
242            self.skip_ws();
243
244            // Parse '='
245            if self.current() == Some(SyntaxKind::EQUALS) {
246                self.bump();
247            } else {
248                self.errors.push("expected '=' after key".to_string());
249            }
250
251            self.skip_ws();
252
253            // Parse value (may include line continuations)
254            while let Some(kind) = self.current() {
255                match kind {
256                    SyntaxKind::VALUE => self.bump(),
257                    SyntaxKind::LINE_CONTINUATION => {
258                        self.bump();
259                        // After line continuation, skip leading whitespace
260                        self.skip_ws();
261                    }
262                    SyntaxKind::NEWLINE => {
263                        self.bump();
264                        break;
265                    }
266                    _ => break,
267                }
268            }
269
270            self.builder.finish_node();
271        }
272
273        fn parse_section(&mut self) {
274            self.builder.start_node(SyntaxKind::SECTION.into());
275
276            // Parse section header
277            self.parse_section_header();
278
279            // Parse entries until we hit another section header or EOF
280            while let Some(kind) = self.current() {
281                match kind {
282                    SyntaxKind::LEFT_BRACKET => break, // Start of next section
283                    SyntaxKind::KEY | SyntaxKind::COMMENT => self.parse_entry(),
284                    SyntaxKind::NEWLINE | SyntaxKind::WHITESPACE => {
285                        self.skip_blank_lines();
286                    }
287                    _ => {
288                        self.errors
289                            .push(format!("unexpected token in section: {:?}", kind));
290                        self.bump();
291                    }
292                }
293            }
294
295            self.builder.finish_node();
296        }
297
298        fn parse_file(&mut self) {
299            self.builder.start_node(SyntaxKind::ROOT.into());
300
301            // Skip leading blank lines and comments
302            while let Some(kind) = self.current() {
303                match kind {
304                    SyntaxKind::COMMENT => {
305                        self.builder.start_node(SyntaxKind::ENTRY.into());
306                        self.bump();
307                        if self.current() == Some(SyntaxKind::NEWLINE) {
308                            self.bump();
309                        }
310                        self.builder.finish_node();
311                    }
312                    SyntaxKind::NEWLINE | SyntaxKind::WHITESPACE => {
313                        self.skip_blank_lines();
314                    }
315                    _ => break,
316                }
317            }
318
319            // Parse sections
320            while self.current().is_some() {
321                if self.current() == Some(SyntaxKind::LEFT_BRACKET) {
322                    self.parse_section();
323                } else {
324                    self.errors
325                        .push(format!("expected section header, got {:?}", self.current()));
326                    self.bump();
327                }
328            }
329
330            self.builder.finish_node();
331        }
332    }
333
334    let mut tokens: Vec<_> = lex(text).collect();
335    tokens.reverse();
336
337    let mut parser = Parser {
338        tokens,
339        builder: GreenNodeBuilder::new(),
340        errors: Vec::new(),
341        positioned_errors: Vec::new(),
342        pos: 0,
343    };
344
345    parser.parse_file();
346
347    ParseResult {
348        green_node: parser.builder.finish(),
349        errors: parser.errors,
350        positioned_errors: parser.positioned_errors,
351    }
352}
353
354// Type aliases for convenience
355type SyntaxNode = rowan::SyntaxNode<Lang>;
356
357/// The root of a systemd unit file
358#[derive(Debug, Clone, PartialEq, Eq, Hash)]
359pub struct SystemdUnit(SyntaxNode);
360
361impl SystemdUnit {
362    /// Get all sections in the file
363    pub fn sections(&self) -> impl Iterator<Item = Section> {
364        self.0.children().filter_map(Section::cast)
365    }
366
367    /// Get a specific section by name
368    pub fn get_section(&self, name: &str) -> Option<Section> {
369        self.sections().find(|s| s.name().as_deref() == Some(name))
370    }
371
372    /// Add a new section to the unit file
373    pub fn add_section(&mut self, name: &str) {
374        let new_section = Section::new(name);
375        let insertion_index = self.0.children_with_tokens().count();
376        self.0
377            .splice_children(insertion_index..insertion_index, vec![new_section.0.into()]);
378    }
379
380    /// Get the raw syntax node
381    pub fn syntax(&self) -> &SyntaxNode {
382        &self.0
383    }
384
385    /// Convert to a string (same as Display::fmt)
386    pub fn text(&self) -> String {
387        self.0.text().to_string()
388    }
389
390    /// Load from a file
391    pub fn from_file(path: &Path) -> Result<Self, Error> {
392        let text = std::fs::read_to_string(path)?;
393        Self::from_str(&text)
394    }
395
396    /// Write to a file
397    pub fn write_to_file(&self, path: &Path) -> Result<(), Error> {
398        std::fs::write(path, self.text())?;
399        Ok(())
400    }
401}
402
403impl AstNode for SystemdUnit {
404    type Language = Lang;
405
406    fn can_cast(kind: SyntaxKind) -> bool {
407        kind == SyntaxKind::ROOT
408    }
409
410    fn cast(node: SyntaxNode) -> Option<Self> {
411        if node.kind() == SyntaxKind::ROOT {
412            Some(SystemdUnit(node))
413        } else {
414            None
415        }
416    }
417
418    fn syntax(&self) -> &SyntaxNode {
419        &self.0
420    }
421}
422
423impl FromStr for SystemdUnit {
424    type Err = Error;
425
426    fn from_str(s: &str) -> Result<Self, Self::Err> {
427        let parsed = parse(s);
428        if !parsed.errors.is_empty() {
429            return Err(Error::ParseError(ParseError(parsed.errors)));
430        }
431        let node = SyntaxNode::new_root_mut(parsed.green_node);
432        Ok(SystemdUnit::cast(node).expect("root node should be SystemdUnit"))
433    }
434}
435
436impl std::fmt::Display for SystemdUnit {
437    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438        write!(f, "{}", self.0.text())
439    }
440}
441
442/// A section in a systemd unit file (e.g., [Unit], [Service])
443#[derive(Debug, Clone, PartialEq, Eq, Hash)]
444pub struct Section(SyntaxNode);
445
446impl Section {
447    /// Create a new section with the given name
448    pub fn new(name: &str) -> Section {
449        use rowan::GreenNodeBuilder;
450
451        let mut builder = GreenNodeBuilder::new();
452        builder.start_node(SyntaxKind::SECTION.into());
453
454        // Build section header
455        builder.start_node(SyntaxKind::SECTION_HEADER.into());
456        builder.token(SyntaxKind::LEFT_BRACKET.into(), "[");
457        builder.token(SyntaxKind::SECTION_NAME.into(), name);
458        builder.token(SyntaxKind::RIGHT_BRACKET.into(), "]");
459        builder.token(SyntaxKind::NEWLINE.into(), "\n");
460        builder.finish_node();
461
462        builder.finish_node();
463        Section(SyntaxNode::new_root_mut(builder.finish()))
464    }
465
466    /// Get the name of the section
467    pub fn name(&self) -> Option<String> {
468        let header = self
469            .0
470            .children()
471            .find(|n| n.kind() == SyntaxKind::SECTION_HEADER)?;
472        let value = header
473            .children_with_tokens()
474            .find(|e| e.kind() == SyntaxKind::SECTION_NAME)?;
475        Some(value.as_token()?.text().to_string())
476    }
477
478    /// Get all entries in the section
479    pub fn entries(&self) -> impl Iterator<Item = Entry> {
480        self.0.children().filter_map(Entry::cast)
481    }
482
483    /// Get a specific entry by key
484    pub fn get(&self, key: &str) -> Option<String> {
485        self.entries()
486            .find(|e| e.key().as_deref() == Some(key))
487            .and_then(|e| e.value())
488    }
489
490    /// Get all values for a key (systemd allows multiple entries with the same key)
491    pub fn get_all(&self, key: &str) -> Vec<String> {
492        self.entries()
493            .filter(|e| e.key().as_deref() == Some(key))
494            .filter_map(|e| e.value())
495            .collect()
496    }
497
498    /// Set a value for a key (replaces the first occurrence or adds if it doesn't exist)
499    pub fn set(&mut self, key: &str, value: &str) {
500        let new_entry = Entry::new(key, value);
501
502        // Check if the field already exists and replace the first occurrence
503        for entry in self.entries() {
504            if entry.key().as_deref() == Some(key) {
505                self.0.splice_children(
506                    entry.0.index()..entry.0.index() + 1,
507                    vec![new_entry.0.into()],
508                );
509                return;
510            }
511        }
512
513        // Field doesn't exist, append at the end (before trailing whitespace)
514        let children: Vec<_> = self.0.children_with_tokens().collect();
515        let insertion_index = children
516            .iter()
517            .enumerate()
518            .rev()
519            .find(|(_, child)| {
520                child.kind() != SyntaxKind::BLANK_LINE
521                    && child.kind() != SyntaxKind::NEWLINE
522                    && child.kind() != SyntaxKind::WHITESPACE
523            })
524            .map(|(idx, _)| idx + 1)
525            .unwrap_or(children.len());
526
527        self.0
528            .splice_children(insertion_index..insertion_index, vec![new_entry.0.into()]);
529    }
530
531    /// Add a value for a key (appends even if the key already exists)
532    pub fn add(&mut self, key: &str, value: &str) {
533        let new_entry = Entry::new(key, value);
534
535        // Find the last non-whitespace child to insert after
536        let children: Vec<_> = self.0.children_with_tokens().collect();
537        let insertion_index = children
538            .iter()
539            .enumerate()
540            .rev()
541            .find(|(_, child)| {
542                child.kind() != SyntaxKind::BLANK_LINE
543                    && child.kind() != SyntaxKind::NEWLINE
544                    && child.kind() != SyntaxKind::WHITESPACE
545            })
546            .map(|(idx, _)| idx + 1)
547            .unwrap_or(children.len());
548
549        self.0
550            .splice_children(insertion_index..insertion_index, vec![new_entry.0.into()]);
551    }
552
553    /// Set a space-separated list value for a key
554    ///
555    /// This is a convenience method for setting list-type directives
556    /// (e.g., `Wants=`, `After=`). The values will be joined with spaces.
557    ///
558    /// # Example
559    ///
560    /// ```
561    /// # use systemd_unit_edit::SystemdUnit;
562    /// # use std::str::FromStr;
563    /// # let mut unit = SystemdUnit::from_str("[Unit]\n").unwrap();
564    /// # let mut section = unit.get_section("Unit").unwrap();
565    /// section.set_list("Wants", &["foo.service", "bar.service"]);
566    /// // Results in: Wants=foo.service bar.service
567    /// ```
568    pub fn set_list(&mut self, key: &str, values: &[&str]) {
569        let value = values.join(" ");
570        self.set(key, &value);
571    }
572
573    /// Get a value parsed as a space-separated list
574    ///
575    /// This is a convenience method for getting list-type directives.
576    /// If the key doesn't exist, returns an empty vector.
577    pub fn get_list(&self, key: &str) -> Vec<String> {
578        self.entries()
579            .find(|e| e.key().as_deref() == Some(key))
580            .map(|e| e.value_as_list())
581            .unwrap_or_default()
582    }
583
584    /// Get a value parsed as a boolean
585    ///
586    /// Returns `None` if the key doesn't exist or if the value is not a valid boolean.
587    ///
588    /// # Example
589    ///
590    /// ```
591    /// # use systemd_unit_edit::SystemdUnit;
592    /// # use std::str::FromStr;
593    /// let unit = SystemdUnit::from_str("[Service]\nRemainAfterExit=yes\n").unwrap();
594    /// let section = unit.get_section("Service").unwrap();
595    /// assert_eq!(section.get_bool("RemainAfterExit"), Some(true));
596    /// ```
597    pub fn get_bool(&self, key: &str) -> Option<bool> {
598        self.entries()
599            .find(|e| e.key().as_deref() == Some(key))
600            .and_then(|e| e.value_as_bool())
601    }
602
603    /// Set a boolean value for a key
604    ///
605    /// This is a convenience method that formats the boolean as "yes" or "no".
606    ///
607    /// # Example
608    ///
609    /// ```
610    /// # use systemd_unit_edit::SystemdUnit;
611    /// # use std::str::FromStr;
612    /// let unit = SystemdUnit::from_str("[Service]\n").unwrap();
613    /// let mut section = unit.get_section("Service").unwrap();
614    /// section.set_bool("RemainAfterExit", true);
615    /// assert_eq!(section.get("RemainAfterExit"), Some("yes".to_string()));
616    /// ```
617    pub fn set_bool(&mut self, key: &str, value: bool) {
618        self.set(key, Entry::format_bool(value));
619    }
620
621    /// Remove the first entry with the given key
622    pub fn remove(&mut self, key: &str) {
623        // Find and remove the first entry with the matching key
624        let entry_to_remove = self.0.children().find_map(|child| {
625            let entry = Entry::cast(child)?;
626            if entry.key().as_deref() == Some(key) {
627                Some(entry)
628            } else {
629                None
630            }
631        });
632
633        if let Some(entry) = entry_to_remove {
634            entry.syntax().detach();
635        }
636    }
637
638    /// Remove all entries with the given key
639    pub fn remove_all(&mut self, key: &str) {
640        // Collect all entries to remove first (can't mutate while iterating)
641        let entries_to_remove: Vec<_> = self
642            .0
643            .children()
644            .filter_map(Entry::cast)
645            .filter(|e| e.key().as_deref() == Some(key))
646            .collect();
647
648        for entry in entries_to_remove {
649            entry.syntax().detach();
650        }
651    }
652
653    /// Get the raw syntax node
654    pub fn syntax(&self) -> &SyntaxNode {
655        &self.0
656    }
657}
658
659impl AstNode for Section {
660    type Language = Lang;
661
662    fn can_cast(kind: SyntaxKind) -> bool {
663        kind == SyntaxKind::SECTION
664    }
665
666    fn cast(node: SyntaxNode) -> Option<Self> {
667        if node.kind() == SyntaxKind::SECTION {
668            Some(Section(node))
669        } else {
670            None
671        }
672    }
673
674    fn syntax(&self) -> &SyntaxNode {
675        &self.0
676    }
677}
678
679/// Unescape a string by processing C-style escape sequences
680fn unescape_string(s: &str) -> String {
681    let mut result = String::new();
682    let mut chars = s.chars().peekable();
683
684    while let Some(ch) = chars.next() {
685        if ch == '\\' {
686            match chars.next() {
687                Some('n') => result.push('\n'),
688                Some('t') => result.push('\t'),
689                Some('r') => result.push('\r'),
690                Some('\\') => result.push('\\'),
691                Some('"') => result.push('"'),
692                Some('\'') => result.push('\''),
693                Some('x') => {
694                    // Hexadecimal byte: \xhh
695                    let hex: String = chars.by_ref().take(2).collect();
696                    if let Ok(byte) = u8::from_str_radix(&hex, 16) {
697                        result.push(byte as char);
698                    } else {
699                        // Invalid escape, keep as-is
700                        result.push('\\');
701                        result.push('x');
702                        result.push_str(&hex);
703                    }
704                }
705                Some('u') => {
706                    // Unicode codepoint: \unnnn
707                    let hex: String = chars.by_ref().take(4).collect();
708                    if let Ok(code) = u32::from_str_radix(&hex, 16) {
709                        if let Some(unicode_char) = char::from_u32(code) {
710                            result.push(unicode_char);
711                        } else {
712                            // Invalid codepoint, keep as-is
713                            result.push('\\');
714                            result.push('u');
715                            result.push_str(&hex);
716                        }
717                    } else {
718                        // Invalid escape, keep as-is
719                        result.push('\\');
720                        result.push('u');
721                        result.push_str(&hex);
722                    }
723                }
724                Some('U') => {
725                    // Unicode codepoint: \Unnnnnnnn
726                    let hex: String = chars.by_ref().take(8).collect();
727                    if let Ok(code) = u32::from_str_radix(&hex, 16) {
728                        if let Some(unicode_char) = char::from_u32(code) {
729                            result.push(unicode_char);
730                        } else {
731                            // Invalid codepoint, keep as-is
732                            result.push('\\');
733                            result.push('U');
734                            result.push_str(&hex);
735                        }
736                    } else {
737                        // Invalid escape, keep as-is
738                        result.push('\\');
739                        result.push('U');
740                        result.push_str(&hex);
741                    }
742                }
743                Some(c) if c.is_ascii_digit() => {
744                    // Octal byte: \nnn (up to 3 digits)
745                    let mut octal = String::from(c);
746                    for _ in 0..2 {
747                        if let Some(&next_ch) = chars.peek() {
748                            if next_ch.is_ascii_digit() && next_ch < '8' {
749                                octal.push(chars.next().unwrap());
750                            } else {
751                                break;
752                            }
753                        }
754                    }
755                    if let Ok(byte) = u8::from_str_radix(&octal, 8) {
756                        result.push(byte as char);
757                    } else {
758                        // Invalid escape, keep as-is
759                        result.push('\\');
760                        result.push_str(&octal);
761                    }
762                }
763                Some(c) => {
764                    // Unknown escape sequence, keep the backslash
765                    result.push('\\');
766                    result.push(c);
767                }
768                None => {
769                    // Backslash at end of string
770                    result.push('\\');
771                }
772            }
773        } else {
774            result.push(ch);
775        }
776    }
777
778    result
779}
780
781/// Escape a string for use in systemd unit files
782fn escape_string(s: &str) -> String {
783    let mut result = String::new();
784
785    for ch in s.chars() {
786        match ch {
787            '\\' => result.push_str("\\\\"),
788            '\n' => result.push_str("\\n"),
789            '\t' => result.push_str("\\t"),
790            '\r' => result.push_str("\\r"),
791            '"' => result.push_str("\\\""),
792            _ => result.push(ch),
793        }
794    }
795
796    result
797}
798
799/// Remove quotes from a string if present
800///
801/// According to systemd specification, quotes (both double and single) are
802/// removed when processing values. This function handles:
803/// - Removing matching outer quotes
804/// - Preserving whitespace inside quotes
805/// - Handling escaped quotes inside quoted strings
806fn unquote_string(s: &str) -> String {
807    let trimmed = s.trim();
808
809    if trimmed.len() < 2 {
810        return trimmed.to_string();
811    }
812
813    let first = trimmed.chars().next();
814    let last = trimmed.chars().last();
815
816    // Check if string is quoted with matching quotes
817    if let (Some('"'), Some('"')) = (first, last) {
818        // Remove outer quotes
819        trimmed[1..trimmed.len() - 1].to_string()
820    } else if let (Some('\''), Some('\'')) = (first, last) {
821        // Remove outer quotes
822        trimmed[1..trimmed.len() - 1].to_string()
823    } else {
824        // Not quoted, return as-is (but trimmed)
825        trimmed.to_string()
826    }
827}
828
829/// A key-value entry in a section
830#[derive(Debug, Clone, PartialEq, Eq, Hash)]
831pub struct Entry(SyntaxNode);
832
833impl Entry {
834    /// Create a new entry with key=value
835    pub fn new(key: &str, value: &str) -> Entry {
836        use rowan::GreenNodeBuilder;
837
838        let mut builder = GreenNodeBuilder::new();
839        builder.start_node(SyntaxKind::ENTRY.into());
840        builder.token(SyntaxKind::KEY.into(), key);
841        builder.token(SyntaxKind::EQUALS.into(), "=");
842        builder.token(SyntaxKind::VALUE.into(), value);
843        builder.token(SyntaxKind::NEWLINE.into(), "\n");
844        builder.finish_node();
845        Entry(SyntaxNode::new_root_mut(builder.finish()))
846    }
847
848    /// Get the key name
849    pub fn key(&self) -> Option<String> {
850        let key_token = self
851            .0
852            .children_with_tokens()
853            .find(|e| e.kind() == SyntaxKind::KEY)?;
854        Some(key_token.as_token()?.text().to_string())
855    }
856
857    /// Get the value (handles line continuations)
858    pub fn value(&self) -> Option<String> {
859        // Find all VALUE tokens after EQUALS, handling line continuations
860        let mut found_equals = false;
861        let mut value_parts = Vec::new();
862
863        for element in self.0.children_with_tokens() {
864            match element.kind() {
865                SyntaxKind::EQUALS => found_equals = true,
866                SyntaxKind::VALUE if found_equals => {
867                    value_parts.push(element.as_token()?.text().to_string());
868                }
869                SyntaxKind::LINE_CONTINUATION if found_equals => {
870                    // Line continuation: backslash-newline is replaced with a space
871                    // But don't add a space if the last value part already ends with whitespace
872                    let should_add_space = value_parts
873                        .last()
874                        .map(|s| !s.ends_with(' ') && !s.ends_with('\t'))
875                        .unwrap_or(true);
876                    if should_add_space {
877                        value_parts.push(" ".to_string());
878                    }
879                }
880                SyntaxKind::WHITESPACE if found_equals && !value_parts.is_empty() => {
881                    // Only include whitespace that's part of the value (after we've started collecting)
882                    // Skip leading whitespace immediately after EQUALS
883                    value_parts.push(element.as_token()?.text().to_string());
884                }
885                SyntaxKind::NEWLINE => break,
886                _ => {}
887            }
888        }
889
890        if value_parts.is_empty() {
891            None
892        } else {
893            // Join all value parts (line continuations already converted to spaces)
894            Some(value_parts.join(""))
895        }
896    }
897
898    /// Get the raw value as it appears in the file (including line continuations)
899    pub fn raw_value(&self) -> Option<String> {
900        let mut found_equals = false;
901        let mut value_parts = Vec::new();
902
903        for element in self.0.children_with_tokens() {
904            match element.kind() {
905                SyntaxKind::EQUALS => found_equals = true,
906                SyntaxKind::VALUE if found_equals => {
907                    value_parts.push(element.as_token()?.text().to_string());
908                }
909                SyntaxKind::LINE_CONTINUATION if found_equals => {
910                    value_parts.push(element.as_token()?.text().to_string());
911                }
912                SyntaxKind::WHITESPACE if found_equals => {
913                    value_parts.push(element.as_token()?.text().to_string());
914                }
915                SyntaxKind::NEWLINE => break,
916                _ => {}
917            }
918        }
919
920        if value_parts.is_empty() {
921            None
922        } else {
923            Some(value_parts.join(""))
924        }
925    }
926
927    /// Get the value with escape sequences processed
928    ///
929    /// This processes C-style escape sequences as defined in the systemd specification:
930    /// - `\n` - newline
931    /// - `\t` - tab
932    /// - `\r` - carriage return
933    /// - `\\` - backslash
934    /// - `\"` - double quote
935    /// - `\'` - single quote
936    /// - `\xhh` - hexadecimal byte (2 digits)
937    /// - `\nnn` - octal byte (3 digits)
938    /// - `\unnnn` - Unicode codepoint (4 hex digits)
939    /// - `\Unnnnnnnn` - Unicode codepoint (8 hex digits)
940    pub fn unescape_value(&self) -> Option<String> {
941        let value = self.value()?;
942        Some(unescape_string(&value))
943    }
944
945    /// Escape a string value for use in systemd unit files
946    ///
947    /// This escapes special characters that need escaping in systemd values:
948    /// - backslash (`\`) becomes `\\`
949    /// - newline (`\n`) becomes `\n`
950    /// - tab (`\t`) becomes `\t`
951    /// - carriage return (`\r`) becomes `\r`
952    /// - double quote (`"`) becomes `\"`
953    pub fn escape_value(value: &str) -> String {
954        escape_string(value)
955    }
956
957    /// Check if the value is quoted (starts and ends with matching quotes)
958    ///
959    /// Returns the quote character if the value is quoted, None otherwise.
960    /// Systemd supports both double quotes (`"`) and single quotes (`'`).
961    pub fn is_quoted(&self) -> Option<char> {
962        let value = self.value()?;
963        let trimmed = value.trim();
964
965        if trimmed.len() < 2 {
966            return None;
967        }
968
969        let first = trimmed.chars().next()?;
970        let last = trimmed.chars().last()?;
971
972        if (first == '"' || first == '\'') && first == last {
973            Some(first)
974        } else {
975            None
976        }
977    }
978
979    /// Get the value with quotes removed (if present)
980    ///
981    /// According to systemd specification, quotes are removed when processing values.
982    /// This method returns the value with outer quotes stripped if present.
983    pub fn unquoted_value(&self) -> Option<String> {
984        let value = self.value()?;
985        Some(unquote_string(&value))
986    }
987
988    /// Get the value with quotes preserved as they appear in the file
989    ///
990    /// This is useful when you want to preserve the exact quoting style.
991    pub fn quoted_value(&self) -> Option<String> {
992        // This is the same as value() - just provided for clarity
993        self.value()
994    }
995
996    /// Parse the value as a space-separated list
997    ///
998    /// Many systemd directives use space-separated lists (e.g., `Wants=`,
999    /// `After=`, `Before=`). This method splits the value on whitespace
1000    /// and returns a vector of strings.
1001    ///
1002    /// Empty values return an empty vector.
1003    pub fn value_as_list(&self) -> Vec<String> {
1004        let value = match self.unquoted_value() {
1005            Some(v) => v,
1006            None => return Vec::new(),
1007        };
1008
1009        value.split_whitespace().map(|s| s.to_string()).collect()
1010    }
1011
1012    /// Parse the value as a boolean
1013    ///
1014    /// According to systemd specification, boolean values accept:
1015    /// - Positive: `1`, `yes`, `true`, `on`
1016    /// - Negative: `0`, `no`, `false`, `off`
1017    ///
1018    /// Returns `None` if the value is not a valid boolean or if the entry has no value.
1019    ///
1020    /// # Example
1021    ///
1022    /// ```
1023    /// # use systemd_unit_edit::SystemdUnit;
1024    /// # use std::str::FromStr;
1025    /// let unit = SystemdUnit::from_str("[Service]\nRemainAfterExit=yes\n").unwrap();
1026    /// let section = unit.get_section("Service").unwrap();
1027    /// let entry = section.entries().next().unwrap();
1028    /// assert_eq!(entry.value_as_bool(), Some(true));
1029    /// ```
1030    pub fn value_as_bool(&self) -> Option<bool> {
1031        let value = self.unquoted_value()?;
1032        let value_lower = value.trim().to_lowercase();
1033
1034        match value_lower.as_str() {
1035            "1" | "yes" | "true" | "on" => Some(true),
1036            "0" | "no" | "false" | "off" => Some(false),
1037            _ => None,
1038        }
1039    }
1040
1041    /// Format a boolean value for use in systemd unit files
1042    ///
1043    /// This converts a boolean to the canonical systemd format:
1044    /// - `true` becomes `"yes"`
1045    /// - `false` becomes `"no"`
1046    ///
1047    /// # Example
1048    ///
1049    /// ```
1050    /// # use systemd_unit_edit::Entry;
1051    /// assert_eq!(Entry::format_bool(true), "yes");
1052    /// assert_eq!(Entry::format_bool(false), "no");
1053    /// ```
1054    pub fn format_bool(value: bool) -> &'static str {
1055        if value {
1056            "yes"
1057        } else {
1058            "no"
1059        }
1060    }
1061
1062    /// Expand systemd specifiers in the value
1063    ///
1064    /// This replaces systemd specifiers like `%i`, `%u`, `%h` with their
1065    /// values from the provided context.
1066    ///
1067    /// # Example
1068    ///
1069    /// ```
1070    /// # use systemd_unit_edit::{SystemdUnit, SpecifierContext};
1071    /// # use std::str::FromStr;
1072    /// let unit = SystemdUnit::from_str("[Service]\nWorkingDirectory=/var/lib/%i\n").unwrap();
1073    /// let section = unit.get_section("Service").unwrap();
1074    /// let entry = section.entries().next().unwrap();
1075    ///
1076    /// let mut ctx = SpecifierContext::new();
1077    /// ctx.set("i", "myinstance");
1078    ///
1079    /// assert_eq!(entry.expand_specifiers(&ctx), Some("/var/lib/myinstance".to_string()));
1080    /// ```
1081    pub fn expand_specifiers(
1082        &self,
1083        context: &crate::specifier::SpecifierContext,
1084    ) -> Option<String> {
1085        let value = self.value()?;
1086        Some(context.expand(&value))
1087    }
1088
1089    /// Get the raw syntax node
1090    pub fn syntax(&self) -> &SyntaxNode {
1091        &self.0
1092    }
1093}
1094
1095impl AstNode for Entry {
1096    type Language = Lang;
1097
1098    fn can_cast(kind: SyntaxKind) -> bool {
1099        kind == SyntaxKind::ENTRY
1100    }
1101
1102    fn cast(node: SyntaxNode) -> Option<Self> {
1103        if node.kind() == SyntaxKind::ENTRY {
1104            Some(Entry(node))
1105        } else {
1106            None
1107        }
1108    }
1109
1110    fn syntax(&self) -> &SyntaxNode {
1111        &self.0
1112    }
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118
1119    #[test]
1120    fn test_parse_simple() {
1121        let input = r#"[Unit]
1122Description=Test Service
1123After=network.target
1124"#;
1125        let unit = SystemdUnit::from_str(input).unwrap();
1126        assert_eq!(unit.sections().count(), 1);
1127
1128        let section = unit.sections().next().unwrap();
1129        assert_eq!(section.name(), Some("Unit".to_string()));
1130        assert_eq!(section.get("Description"), Some("Test Service".to_string()));
1131        assert_eq!(section.get("After"), Some("network.target".to_string()));
1132    }
1133
1134    #[test]
1135    fn test_parse_with_comments() {
1136        let input = r#"# Top comment
1137[Unit]
1138# Comment before description
1139Description=Test Service
1140; Semicolon comment
1141After=network.target
1142"#;
1143        let unit = SystemdUnit::from_str(input).unwrap();
1144        assert_eq!(unit.sections().count(), 1);
1145
1146        let section = unit.sections().next().unwrap();
1147        assert_eq!(section.get("Description"), Some("Test Service".to_string()));
1148    }
1149
1150    #[test]
1151    fn test_parse_multiple_sections() {
1152        let input = r#"[Unit]
1153Description=Test Service
1154
1155[Service]
1156Type=simple
1157ExecStart=/usr/bin/test
1158
1159[Install]
1160WantedBy=multi-user.target
1161"#;
1162        let unit = SystemdUnit::from_str(input).unwrap();
1163        assert_eq!(unit.sections().count(), 3);
1164
1165        let unit_section = unit.get_section("Unit").unwrap();
1166        assert_eq!(
1167            unit_section.get("Description"),
1168            Some("Test Service".to_string())
1169        );
1170
1171        let service_section = unit.get_section("Service").unwrap();
1172        assert_eq!(service_section.get("Type"), Some("simple".to_string()));
1173        assert_eq!(
1174            service_section.get("ExecStart"),
1175            Some("/usr/bin/test".to_string())
1176        );
1177
1178        let install_section = unit.get_section("Install").unwrap();
1179        assert_eq!(
1180            install_section.get("WantedBy"),
1181            Some("multi-user.target".to_string())
1182        );
1183    }
1184
1185    #[test]
1186    fn test_parse_with_spaces() {
1187        let input = "[Unit]\nDescription = Test Service\n";
1188        let unit = SystemdUnit::from_str(input).unwrap();
1189
1190        let section = unit.sections().next().unwrap();
1191        assert_eq!(section.get("Description"), Some("Test Service".to_string()));
1192    }
1193
1194    #[test]
1195    fn test_line_continuation() {
1196        let input = "[Service]\nExecStart=/bin/echo \\\n  hello world\n";
1197        let unit = SystemdUnit::from_str(input).unwrap();
1198
1199        let section = unit.sections().next().unwrap();
1200        let entry = section.entries().next().unwrap();
1201        assert_eq!(entry.key(), Some("ExecStart".to_string()));
1202        // Line continuation: backslash is replaced with space
1203        assert_eq!(entry.value(), Some("/bin/echo   hello world".to_string()));
1204    }
1205
1206    #[test]
1207    fn test_lossless_roundtrip() {
1208        let input = r#"# Comment
1209[Unit]
1210Description=Test Service
1211After=network.target
1212
1213[Service]
1214Type=simple
1215ExecStart=/usr/bin/test
1216"#;
1217        let unit = SystemdUnit::from_str(input).unwrap();
1218        let output = unit.text();
1219        assert_eq!(input, output);
1220    }
1221
1222    #[test]
1223    fn test_set_value() {
1224        let input = r#"[Unit]
1225Description=Test Service
1226"#;
1227        let unit = SystemdUnit::from_str(input).unwrap();
1228        {
1229            let mut section = unit.sections().next().unwrap();
1230            section.set("Description", "Updated Service");
1231        }
1232
1233        let section = unit.sections().next().unwrap();
1234        assert_eq!(
1235            section.get("Description"),
1236            Some("Updated Service".to_string())
1237        );
1238    }
1239
1240    #[test]
1241    fn test_add_new_entry() {
1242        let input = r#"[Unit]
1243Description=Test Service
1244"#;
1245        let unit = SystemdUnit::from_str(input).unwrap();
1246        {
1247            let mut section = unit.sections().next().unwrap();
1248            section.set("After", "network.target");
1249        }
1250
1251        let section = unit.sections().next().unwrap();
1252        assert_eq!(section.get("Description"), Some("Test Service".to_string()));
1253        assert_eq!(section.get("After"), Some("network.target".to_string()));
1254    }
1255
1256    #[test]
1257    fn test_multiple_values_same_key() {
1258        let input = r#"[Unit]
1259Wants=foo.service
1260Wants=bar.service
1261"#;
1262        let unit = SystemdUnit::from_str(input).unwrap();
1263        let section = unit.sections().next().unwrap();
1264
1265        // get() returns the first value
1266        assert_eq!(section.get("Wants"), Some("foo.service".to_string()));
1267
1268        // get_all() returns all values
1269        let all_wants = section.get_all("Wants");
1270        assert_eq!(all_wants.len(), 2);
1271        assert_eq!(all_wants[0], "foo.service");
1272        assert_eq!(all_wants[1], "bar.service");
1273    }
1274
1275    #[test]
1276    fn test_add_multiple_entries() {
1277        let input = r#"[Unit]
1278Description=Test Service
1279"#;
1280        let unit = SystemdUnit::from_str(input).unwrap();
1281        {
1282            let mut section = unit.sections().next().unwrap();
1283            section.add("Wants", "foo.service");
1284            section.add("Wants", "bar.service");
1285        }
1286
1287        let section = unit.sections().next().unwrap();
1288        let all_wants = section.get_all("Wants");
1289        assert_eq!(all_wants.len(), 2);
1290        assert_eq!(all_wants[0], "foo.service");
1291        assert_eq!(all_wants[1], "bar.service");
1292    }
1293
1294    #[test]
1295    fn test_remove_entry() {
1296        let input = r#"[Unit]
1297Description=Test Service
1298After=network.target
1299"#;
1300        let unit = SystemdUnit::from_str(input).unwrap();
1301        {
1302            let mut section = unit.sections().next().unwrap();
1303            section.remove("After");
1304        }
1305
1306        let section = unit.sections().next().unwrap();
1307        assert_eq!(section.get("Description"), Some("Test Service".to_string()));
1308        assert_eq!(section.get("After"), None);
1309    }
1310
1311    #[test]
1312    fn test_remove_all_entries() {
1313        let input = r#"[Unit]
1314Wants=foo.service
1315Wants=bar.service
1316Description=Test
1317"#;
1318        let unit = SystemdUnit::from_str(input).unwrap();
1319        {
1320            let mut section = unit.sections().next().unwrap();
1321            section.remove_all("Wants");
1322        }
1323
1324        let section = unit.sections().next().unwrap();
1325        assert_eq!(section.get_all("Wants").len(), 0);
1326        assert_eq!(section.get("Description"), Some("Test".to_string()));
1327    }
1328
1329    #[test]
1330    fn test_unescape_basic() {
1331        let input = r#"[Unit]
1332Description=Test\nService
1333"#;
1334        let unit = SystemdUnit::from_str(input).unwrap();
1335        let section = unit.sections().next().unwrap();
1336        let entry = section.entries().next().unwrap();
1337
1338        assert_eq!(entry.value(), Some("Test\\nService".to_string()));
1339        assert_eq!(entry.unescape_value(), Some("Test\nService".to_string()));
1340    }
1341
1342    #[test]
1343    fn test_unescape_all_escapes() {
1344        let input = r#"[Unit]
1345Value=\n\t\r\\\"\'\x41\101\u0041\U00000041
1346"#;
1347        let unit = SystemdUnit::from_str(input).unwrap();
1348        let section = unit.sections().next().unwrap();
1349        let entry = section.entries().next().unwrap();
1350
1351        let unescaped = entry.unescape_value().unwrap();
1352        // \n = newline, \t = tab, \r = carriage return, \\ = backslash
1353        // \" = quote, \' = single quote
1354        // \x41 = 'A', \101 = 'A', \u0041 = 'A', \U00000041 = 'A'
1355        assert_eq!(unescaped, "\n\t\r\\\"'AAAA");
1356    }
1357
1358    #[test]
1359    fn test_unescape_unicode() {
1360        let input = r#"[Unit]
1361Value=Hello\u0020World\U0001F44D
1362"#;
1363        let unit = SystemdUnit::from_str(input).unwrap();
1364        let section = unit.sections().next().unwrap();
1365        let entry = section.entries().next().unwrap();
1366
1367        let unescaped = entry.unescape_value().unwrap();
1368        // \u0020 = space, \U0001F44D = ๐Ÿ‘
1369        assert_eq!(unescaped, "Hello World๐Ÿ‘");
1370    }
1371
1372    #[test]
1373    fn test_escape_value() {
1374        let text = "Hello\nWorld\t\"Test\"\\Path";
1375        let escaped = Entry::escape_value(text);
1376        assert_eq!(escaped, "Hello\\nWorld\\t\\\"Test\\\"\\\\Path");
1377    }
1378
1379    #[test]
1380    fn test_escape_unescape_roundtrip() {
1381        let original = "Test\nwith\ttabs\rand\"quotes\"\\backslash";
1382        let escaped = Entry::escape_value(original);
1383        let unescaped = unescape_string(&escaped);
1384        assert_eq!(original, unescaped);
1385    }
1386
1387    #[test]
1388    fn test_unescape_invalid_sequences() {
1389        // Invalid escape sequences should be kept as-is or handled gracefully
1390        let input = r#"[Unit]
1391Value=\z\xFF\u12\U1234
1392"#;
1393        let unit = SystemdUnit::from_str(input).unwrap();
1394        let section = unit.sections().next().unwrap();
1395        let entry = section.entries().next().unwrap();
1396
1397        let unescaped = entry.unescape_value().unwrap();
1398        // \z is unknown, \xFF has only 2 chars but needs hex, \u12 and \U1234 are incomplete
1399        assert!(unescaped.contains("\\z"));
1400    }
1401
1402    #[test]
1403    fn test_quoted_double_quotes() {
1404        let input = r#"[Unit]
1405Description="Test Service"
1406"#;
1407        let unit = SystemdUnit::from_str(input).unwrap();
1408        let section = unit.sections().next().unwrap();
1409        let entry = section.entries().next().unwrap();
1410
1411        assert_eq!(entry.value(), Some("\"Test Service\"".to_string()));
1412        assert_eq!(entry.quoted_value(), Some("\"Test Service\"".to_string()));
1413        assert_eq!(entry.unquoted_value(), Some("Test Service".to_string()));
1414        assert_eq!(entry.is_quoted(), Some('"'));
1415    }
1416
1417    #[test]
1418    fn test_quoted_single_quotes() {
1419        let input = r#"[Unit]
1420Description='Test Service'
1421"#;
1422        let unit = SystemdUnit::from_str(input).unwrap();
1423        let section = unit.sections().next().unwrap();
1424        let entry = section.entries().next().unwrap();
1425
1426        assert_eq!(entry.value(), Some("'Test Service'".to_string()));
1427        assert_eq!(entry.unquoted_value(), Some("Test Service".to_string()));
1428        assert_eq!(entry.is_quoted(), Some('\''));
1429    }
1430
1431    #[test]
1432    fn test_quoted_with_whitespace() {
1433        let input = r#"[Unit]
1434Description="  Test Service  "
1435"#;
1436        let unit = SystemdUnit::from_str(input).unwrap();
1437        let section = unit.sections().next().unwrap();
1438        let entry = section.entries().next().unwrap();
1439
1440        // Quotes preserve internal whitespace
1441        assert_eq!(entry.unquoted_value(), Some("  Test Service  ".to_string()));
1442    }
1443
1444    #[test]
1445    fn test_unquoted_value() {
1446        let input = r#"[Unit]
1447Description=Test Service
1448"#;
1449        let unit = SystemdUnit::from_str(input).unwrap();
1450        let section = unit.sections().next().unwrap();
1451        let entry = section.entries().next().unwrap();
1452
1453        assert_eq!(entry.value(), Some("Test Service".to_string()));
1454        assert_eq!(entry.unquoted_value(), Some("Test Service".to_string()));
1455        assert_eq!(entry.is_quoted(), None);
1456    }
1457
1458    #[test]
1459    fn test_mismatched_quotes() {
1460        let input = r#"[Unit]
1461Description="Test Service'
1462"#;
1463        let unit = SystemdUnit::from_str(input).unwrap();
1464        let section = unit.sections().next().unwrap();
1465        let entry = section.entries().next().unwrap();
1466
1467        // Mismatched quotes should not be considered quoted
1468        assert_eq!(entry.is_quoted(), None);
1469        assert_eq!(entry.unquoted_value(), Some("\"Test Service'".to_string()));
1470    }
1471
1472    #[test]
1473    fn test_empty_quotes() {
1474        let input = r#"[Unit]
1475Description=""
1476"#;
1477        let unit = SystemdUnit::from_str(input).unwrap();
1478        let section = unit.sections().next().unwrap();
1479        let entry = section.entries().next().unwrap();
1480
1481        assert_eq!(entry.is_quoted(), Some('"'));
1482        assert_eq!(entry.unquoted_value(), Some("".to_string()));
1483    }
1484
1485    #[test]
1486    fn test_value_as_list() {
1487        let input = r#"[Unit]
1488After=network.target remote-fs.target
1489"#;
1490        let unit = SystemdUnit::from_str(input).unwrap();
1491        let section = unit.sections().next().unwrap();
1492        let entry = section.entries().next().unwrap();
1493
1494        let list = entry.value_as_list();
1495        assert_eq!(list.len(), 2);
1496        assert_eq!(list[0], "network.target");
1497        assert_eq!(list[1], "remote-fs.target");
1498    }
1499
1500    #[test]
1501    fn test_value_as_list_single() {
1502        let input = r#"[Unit]
1503After=network.target
1504"#;
1505        let unit = SystemdUnit::from_str(input).unwrap();
1506        let section = unit.sections().next().unwrap();
1507        let entry = section.entries().next().unwrap();
1508
1509        let list = entry.value_as_list();
1510        assert_eq!(list.len(), 1);
1511        assert_eq!(list[0], "network.target");
1512    }
1513
1514    #[test]
1515    fn test_value_as_list_empty() {
1516        let input = r#"[Unit]
1517After=
1518"#;
1519        let unit = SystemdUnit::from_str(input).unwrap();
1520        let section = unit.sections().next().unwrap();
1521        let entry = section.entries().next().unwrap();
1522
1523        let list = entry.value_as_list();
1524        assert_eq!(list.len(), 0);
1525    }
1526
1527    #[test]
1528    fn test_value_as_list_with_extra_whitespace() {
1529        let input = r#"[Unit]
1530After=  network.target   remote-fs.target
1531"#;
1532        let unit = SystemdUnit::from_str(input).unwrap();
1533        let section = unit.sections().next().unwrap();
1534        let entry = section.entries().next().unwrap();
1535
1536        let list = entry.value_as_list();
1537        assert_eq!(list.len(), 2);
1538        assert_eq!(list[0], "network.target");
1539        assert_eq!(list[1], "remote-fs.target");
1540    }
1541
1542    #[test]
1543    fn test_section_get_list() {
1544        let input = r#"[Unit]
1545After=network.target remote-fs.target
1546"#;
1547        let unit = SystemdUnit::from_str(input).unwrap();
1548        let section = unit.sections().next().unwrap();
1549
1550        let list = section.get_list("After");
1551        assert_eq!(list.len(), 2);
1552        assert_eq!(list[0], "network.target");
1553        assert_eq!(list[1], "remote-fs.target");
1554    }
1555
1556    #[test]
1557    fn test_section_get_list_missing() {
1558        let input = r#"[Unit]
1559Description=Test
1560"#;
1561        let unit = SystemdUnit::from_str(input).unwrap();
1562        let section = unit.sections().next().unwrap();
1563
1564        let list = section.get_list("After");
1565        assert_eq!(list.len(), 0);
1566    }
1567
1568    #[test]
1569    fn test_section_set_list() {
1570        let input = r#"[Unit]
1571Description=Test
1572"#;
1573        let unit = SystemdUnit::from_str(input).unwrap();
1574        {
1575            let mut section = unit.sections().next().unwrap();
1576            section.set_list("After", &["network.target", "remote-fs.target"]);
1577        }
1578
1579        let section = unit.sections().next().unwrap();
1580        let list = section.get_list("After");
1581        assert_eq!(list.len(), 2);
1582        assert_eq!(list[0], "network.target");
1583        assert_eq!(list[1], "remote-fs.target");
1584    }
1585
1586    #[test]
1587    fn test_section_set_list_replaces() {
1588        let input = r#"[Unit]
1589After=foo.target
1590"#;
1591        let unit = SystemdUnit::from_str(input).unwrap();
1592        {
1593            let mut section = unit.sections().next().unwrap();
1594            section.set_list("After", &["network.target", "remote-fs.target"]);
1595        }
1596
1597        let section = unit.sections().next().unwrap();
1598        let list = section.get_list("After");
1599        assert_eq!(list.len(), 2);
1600        assert_eq!(list[0], "network.target");
1601        assert_eq!(list[1], "remote-fs.target");
1602    }
1603
1604    #[test]
1605    fn test_value_as_bool_positive() {
1606        let inputs = vec!["yes", "true", "1", "on", "YES", "True", "ON"];
1607
1608        for input_val in inputs {
1609            let input = format!("[Service]\nRemainAfterExit={}\n", input_val);
1610            let unit = SystemdUnit::from_str(&input).unwrap();
1611            let section = unit.sections().next().unwrap();
1612            let entry = section.entries().next().unwrap();
1613            assert_eq!(
1614                entry.value_as_bool(),
1615                Some(true),
1616                "Failed for input: {}",
1617                input_val
1618            );
1619        }
1620    }
1621
1622    #[test]
1623    fn test_value_as_bool_negative() {
1624        let inputs = vec!["no", "false", "0", "off", "NO", "False", "OFF"];
1625
1626        for input_val in inputs {
1627            let input = format!("[Service]\nRemainAfterExit={}\n", input_val);
1628            let unit = SystemdUnit::from_str(&input).unwrap();
1629            let section = unit.sections().next().unwrap();
1630            let entry = section.entries().next().unwrap();
1631            assert_eq!(
1632                entry.value_as_bool(),
1633                Some(false),
1634                "Failed for input: {}",
1635                input_val
1636            );
1637        }
1638    }
1639
1640    #[test]
1641    fn test_value_as_bool_invalid() {
1642        let input = r#"[Service]
1643RemainAfterExit=maybe
1644"#;
1645        let unit = SystemdUnit::from_str(input).unwrap();
1646        let section = unit.sections().next().unwrap();
1647        let entry = section.entries().next().unwrap();
1648        assert_eq!(entry.value_as_bool(), None);
1649    }
1650
1651    #[test]
1652    fn test_value_as_bool_with_whitespace() {
1653        let input = r#"[Service]
1654RemainAfterExit=  yes
1655"#;
1656        let unit = SystemdUnit::from_str(input).unwrap();
1657        let section = unit.sections().next().unwrap();
1658        let entry = section.entries().next().unwrap();
1659        assert_eq!(entry.value_as_bool(), Some(true));
1660    }
1661
1662    #[test]
1663    fn test_format_bool() {
1664        assert_eq!(Entry::format_bool(true), "yes");
1665        assert_eq!(Entry::format_bool(false), "no");
1666    }
1667
1668    #[test]
1669    fn test_section_get_bool() {
1670        let input = r#"[Service]
1671RemainAfterExit=yes
1672Type=simple
1673"#;
1674        let unit = SystemdUnit::from_str(input).unwrap();
1675        let section = unit.sections().next().unwrap();
1676
1677        assert_eq!(section.get_bool("RemainAfterExit"), Some(true));
1678        assert_eq!(section.get_bool("Type"), None); // Not a boolean
1679        assert_eq!(section.get_bool("Missing"), None); // Doesn't exist
1680    }
1681
1682    #[test]
1683    fn test_section_set_bool() {
1684        let input = r#"[Service]
1685Type=simple
1686"#;
1687        let unit = SystemdUnit::from_str(input).unwrap();
1688        {
1689            let mut section = unit.sections().next().unwrap();
1690            section.set_bool("RemainAfterExit", true);
1691            section.set_bool("PrivateTmp", false);
1692        }
1693
1694        let section = unit.sections().next().unwrap();
1695        assert_eq!(section.get("RemainAfterExit"), Some("yes".to_string()));
1696        assert_eq!(section.get("PrivateTmp"), Some("no".to_string()));
1697        assert_eq!(section.get_bool("RemainAfterExit"), Some(true));
1698        assert_eq!(section.get_bool("PrivateTmp"), Some(false));
1699    }
1700
1701    #[test]
1702    fn test_add_entry_with_trailing_whitespace() {
1703        // Section with trailing blank lines
1704        let input = r#"[Unit]
1705Description=Test Service
1706
1707"#;
1708        let unit = SystemdUnit::from_str(input).unwrap();
1709        {
1710            let mut section = unit.sections().next().unwrap();
1711            section.add("After", "network.target");
1712        }
1713
1714        let output = unit.text();
1715        // New entry should be added immediately after the last entry, not after whitespace
1716        let expected = r#"[Unit]
1717Description=Test Service
1718After=network.target
1719
1720"#;
1721        assert_eq!(output, expected);
1722    }
1723
1724    #[test]
1725    fn test_set_new_entry_with_trailing_whitespace() {
1726        // Section with trailing blank lines
1727        let input = r#"[Unit]
1728Description=Test Service
1729
1730"#;
1731        let unit = SystemdUnit::from_str(input).unwrap();
1732        {
1733            let mut section = unit.sections().next().unwrap();
1734            section.set("After", "network.target");
1735        }
1736
1737        let output = unit.text();
1738        // New entry should be added immediately after the last entry, not after whitespace
1739        let expected = r#"[Unit]
1740Description=Test Service
1741After=network.target
1742
1743"#;
1744        assert_eq!(output, expected);
1745    }
1746}