systemd-unit-edit-0.1.1/.cargo_vcs_info.json0000644000000001360000000000100144230ustar { "git": { "sha1": "4b65ddb61ad3129da4b0b9cec7f36b324a6543b6" }, "path_in_vcs": "" }systemd-unit-edit-0.1.1/.github/workflows/ci.yml000064400000000000000000000020221046102023000177220ustar 00000000000000name: CI on: push: branches: [ master ] pull_request: branches: [ master ] env: CARGO_TERM_COLOR: always jobs: test: name: Test runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] rust: [stable] steps: - uses: actions/checkout@v4 - name: Install Rust uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.rust }} - name: Run tests run: cargo test --verbose fmt: name: Format runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Rust uses: dtolnay/rust-toolchain@stable with: components: rustfmt - name: Check formatting run: cargo fmt -- --check clippy: name: Clippy runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Rust uses: dtolnay/rust-toolchain@stable with: components: clippy - name: Run clippy run: cargo clippy -- -D warnings systemd-unit-edit-0.1.1/.gitignore000064400000000000000000000000231046102023000151760ustar 00000000000000/target Cargo.lock systemd-unit-edit-0.1.1/Cargo.lock0000644000000023120000000000100123740ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "countme" version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "rowan" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" dependencies = [ "countme", "hashbrown", "rustc-hash", "text-size", ] [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "systemd-unit-edit" version = "0.1.1" dependencies = [ "rowan", ] [[package]] name = "text-size" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" systemd-unit-edit-0.1.1/Cargo.toml0000644000000020550000000000100124230ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" name = "systemd-unit-edit" version = "0.1.1" authors = ["Jelmer Vernooij "] build = false autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "A lossless parser and editor for systemd unit files" homepage = "https://github.com/jelmer/systemd-unit-edit" readme = "README.md" keywords = [ "systemd", "unit", "parser", "lossless", ] categories = ["parser-implementations"] license = "Apache-2.0" [lib] name = "systemd_unit_edit" path = "src/lib.rs" [dependencies.rowan] version = "0.16" systemd-unit-edit-0.1.1/Cargo.toml.orig000064400000000000000000000006121046102023000161010ustar 00000000000000[package] name = "systemd-unit-edit" version = "0.1.1" edition = "2021" authors = ["Jelmer Vernooij "] license = "Apache-2.0" description = "A lossless parser and editor for systemd unit files" keywords = ["systemd", "unit", "parser", "lossless"] categories = ["parser-implementations"] homepage = "https://github.com/jelmer/systemd-unit-edit" [dependencies] rowan = "0.16" systemd-unit-edit-0.1.1/README.md000064400000000000000000000054131046102023000144750ustar 00000000000000# systemd-unit-edit A lossless parser and editor for systemd unit files as specified by the [systemd.syntax(7)](https://www.freedesktop.org/software/systemd/man/latest/systemd.syntax.html) and [systemd.unit(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html) specifications. This library preserves all whitespace, comments, and formatting while providing a structured way to read and modify systemd unit files. ## Features - **Lossless parsing**: All whitespace, comments, and formatting are preserved - **Systemd unit file support**: Full support for the systemd unit file format - **Line continuation support**: Handles backslash line continuations - **Comment support**: Supports both `#` and `;` style comments - **Multiple values**: Supports multiple entries with the same key (common in systemd) ## Example ```rust use systemd_unit_edit::SystemdUnit; use std::str::FromStr; let input = r#"[Unit] Description=Test Service After=network.target [Service] Type=simple ExecStart=/usr/bin/test "#; let unit = SystemdUnit::from_str(input).unwrap(); // Read sections for section in unit.sections() { println!("Section: {}", section.name().unwrap()); for entry in section.entries() { println!(" {} = {}", entry.key().unwrap(), entry.value().unwrap()); } } // Modify values let mut service_section = unit.get_section("Service").unwrap(); service_section.set("Type", "forking"); // Add new entries service_section.add("ExecReload", "/bin/kill -HUP $MAINPID"); // Write back to string println!("{}", unit); ``` ## API ### SystemdUnit The root type representing a parsed systemd unit file. - `SystemdUnit::from_str(text)` - Parse from a string - `SystemdUnit::from_file(path)` - Load from a file - `unit.sections()` - Iterate over all sections - `unit.get_section(name)` - Get a specific section by name - `unit.add_section(name)` - Add a new section - `unit.text()` - Convert back to string (lossless) - `unit.write_to_file(path)` - Write to a file ### Section Represents a section in a unit file (e.g., `[Unit]`, `[Service]`). - `section.name()` - Get the section name - `section.entries()` - Iterate over all entries - `section.get(key)` - Get the first value for a key - `section.get_all(key)` - Get all values for a key (for multi-value keys) - `section.set(key, value)` - Set a value (replaces first occurrence) - `section.add(key, value)` - Add a value (appends even if key exists) - `section.remove(key)` - Remove the first entry with the key - `section.remove_all(key)` - Remove all entries with the key ### Entry Represents a key-value entry in a section. - `entry.key()` - Get the key name - `entry.value()` - Get the value (with line continuations processed) - `entry.raw_value()` - Get the raw value as it appears in the file ## License Apache-2.0 systemd-unit-edit-0.1.1/src/dropin.rs000064400000000000000000000171131046102023000156460ustar 00000000000000//! Drop-in directory support for systemd unit files //! //! This module provides functionality for loading and merging systemd drop-in //! configuration files. use crate::unit::{Error, SystemdUnit}; use std::path::Path; impl SystemdUnit { /// Load a unit file with drop-in configuration files merged /// /// This loads the main unit file and then merges all `.conf` files from /// the drop-in directory (`.d/`). Drop-in files are applied in /// lexicographic order. /// /// Drop-in directories are searched in the same directory as the unit file. /// For example, if loading `/etc/systemd/system/foo.service`, this will /// look for drop-ins in `/etc/systemd/system/foo.service.d/*.conf`. /// /// # Example /// /// ```no_run /// # use systemd_unit_edit::SystemdUnit; /// # use std::path::Path; /// // Loads foo.service and merges foo.service.d/*.conf /// let unit = SystemdUnit::from_file_with_dropins( /// Path::new("/etc/systemd/system/foo.service") /// ).unwrap(); /// ``` pub fn from_file_with_dropins(path: &Path) -> Result { // Load the main unit file let mut unit = Self::from_file(path)?; // Determine the drop-in directory path let mut dropin_dir = path.to_path_buf(); dropin_dir.set_extension(format!( "{}.d", path.extension().and_then(|e| e.to_str()).unwrap_or("") )); // If the drop-in directory exists, load and merge all .conf files if dropin_dir.is_dir() { let mut entries: Vec<_> = std::fs::read_dir(&dropin_dir)? .filter_map(|e| e.ok()) .filter(|e| e.path().extension().and_then(|ext| ext.to_str()) == Some("conf")) .collect(); // Sort by filename (lexicographic order) entries.sort_by_key(|e| e.file_name()); // Merge each drop-in file for entry in entries { let dropin = Self::from_file(&entry.path())?; unit.merge_dropin(&dropin); } } Ok(unit) } /// Merge a drop-in unit file into this unit /// /// This applies the settings from a drop-in file to the current unit. /// According to systemd behavior: /// - New sections are added /// - Existing keys are replaced with values from the drop-in /// - Multiple values for the same key (e.g., `Wants=`) are accumulated /// for directives that support accumulation /// /// # Example /// /// ``` /// # use systemd_unit_edit::SystemdUnit; /// # use std::str::FromStr; /// let mut main = SystemdUnit::from_str("[Unit]\nDescription=Main\n").unwrap(); /// let dropin = SystemdUnit::from_str("[Unit]\nAfter=network.target\n").unwrap(); /// /// main.merge_dropin(&dropin); /// /// let section = main.get_section("Unit").unwrap(); /// assert_eq!(section.get("Description"), Some("Main".to_string())); /// assert_eq!(section.get("After"), Some("network.target".to_string())); /// ``` pub fn merge_dropin(&mut self, dropin: &SystemdUnit) { for dropin_section in dropin.sections() { let section_name = match dropin_section.name() { Some(name) => name, None => continue, }; // Find or create the corresponding section in the main unit let mut main_section = match self.get_section(§ion_name) { Some(section) => section, None => { // Section doesn't exist, add it self.add_section(§ion_name); self.get_section(§ion_name).unwrap() } }; // Merge entries from the drop-in section for entry in dropin_section.entries() { let key = match entry.key() { Some(k) => k, None => continue, }; let value = match entry.value() { Some(v) => v, None => continue, }; // For accumulating directives (like Wants, After, etc.), // add rather than replace. For others, replace. if crate::systemd_metadata::is_accumulating_directive(&key) { main_section.add(&key, &value); } else { main_section.set(&key, &value); } } } } } #[cfg(test)] mod tests { use super::*; use std::str::FromStr; #[test] fn test_merge_dropin_basic() { let mut main = SystemdUnit::from_str("[Unit]\nDescription=Main\n").unwrap(); let dropin = SystemdUnit::from_str("[Unit]\nAfter=network.target\n").unwrap(); main.merge_dropin(&dropin); let section = main.get_section("Unit").unwrap(); assert_eq!(section.get("Description"), Some("Main".to_string())); assert_eq!(section.get("After"), Some("network.target".to_string())); } #[test] fn test_merge_dropin_replaces_non_accumulating() { let mut main = SystemdUnit::from_str("[Unit]\nDescription=Main\n").unwrap(); let dropin = SystemdUnit::from_str("[Unit]\nDescription=Updated\n").unwrap(); main.merge_dropin(&dropin); let section = main.get_section("Unit").unwrap(); assert_eq!(section.get("Description"), Some("Updated".to_string())); } #[test] fn test_merge_dropin_accumulates() { let mut main = SystemdUnit::from_str("[Unit]\nWants=foo.service\nAfter=foo.service\n").unwrap(); let dropin = SystemdUnit::from_str("[Unit]\nWants=bar.service\nAfter=bar.service\n").unwrap(); main.merge_dropin(&dropin); let section = main.get_section("Unit").unwrap(); let wants = section.get_all("Wants"); assert_eq!(wants.len(), 2); assert!(wants.contains(&"foo.service".to_string())); assert!(wants.contains(&"bar.service".to_string())); let after = section.get_all("After"); assert_eq!(after.len(), 2); assert!(after.contains(&"foo.service".to_string())); assert!(after.contains(&"bar.service".to_string())); } #[test] fn test_merge_dropin_new_section() { let mut main = SystemdUnit::from_str("[Unit]\nDescription=Main\n").unwrap(); let dropin = SystemdUnit::from_str("[Service]\nType=simple\n").unwrap(); main.merge_dropin(&dropin); assert_eq!(main.sections().count(), 2); let service = main.get_section("Service").unwrap(); assert_eq!(service.get("Type"), Some("simple".to_string())); } #[test] fn test_merge_dropin_mixed() { let mut main = SystemdUnit::from_str( "[Unit]\nDescription=Main\nWants=foo.service\n\n[Service]\nType=simple\n", ) .unwrap(); let dropin = SystemdUnit::from_str( "[Unit]\nAfter=network.target\nWants=bar.service\n\n[Service]\nRestart=always\n", ) .unwrap(); main.merge_dropin(&dropin); let unit_section = main.get_section("Unit").unwrap(); assert_eq!(unit_section.get("Description"), Some("Main".to_string())); assert_eq!( unit_section.get("After"), Some("network.target".to_string()) ); let wants = unit_section.get_all("Wants"); assert_eq!(wants.len(), 2); let service_section = main.get_section("Service").unwrap(); assert_eq!(service_section.get("Type"), Some("simple".to_string())); assert_eq!(service_section.get("Restart"), Some("always".to_string())); } } systemd-unit-edit-0.1.1/src/lex.rs000064400000000000000000000263661046102023000151550ustar 00000000000000//! Lexer for systemd unit files /// Token types for systemd unit files #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[allow(non_camel_case_types)] #[repr(u16)] pub enum SyntaxKind { /// Left bracket: `[` LEFT_BRACKET = 0, /// Right bracket: `]` RIGHT_BRACKET, /// Equals sign: `=` EQUALS, /// Key name (e.g., "Type", "ExecStart") KEY, /// Section name (e.g., "Unit", "Service") SECTION_NAME, /// Value part of key=value VALUE, /// Comment starting with `#` or `;` COMMENT, /// Newline: `\n` or `\r\n` NEWLINE, /// Whitespace: spaces and tabs WHITESPACE, /// Line continuation: backslash at end of line LINE_CONTINUATION, /// Error token ERROR, /// Root node: the entire file ROOT, /// Section node: a section with its entries SECTION, /// Section header node: `[Section Name]` SECTION_HEADER, /// Entry node: `Key=Value` ENTRY, /// Blank line node BLANK_LINE, } /// Convert our `SyntaxKind` into the rowan `SyntaxKind`. impl From for rowan::SyntaxKind { fn from(kind: SyntaxKind) -> Self { Self(kind as u16) } } /// Check if a character is valid at the start of a key name #[inline] fn is_valid_initial_key_char(c: char) -> bool { // Keys must start with A-Za-z c.is_ascii_alphabetic() } /// Check if a character is valid in a key name #[inline] fn is_valid_key_char(c: char) -> bool { // Keys can contain A-Za-z0-9_- c.is_ascii_alphanumeric() || c == '-' || c == '_' } /// Check if a character is a newline #[inline] fn is_newline(c: char) -> bool { c == '\n' || c == '\r' } /// Check if a character is whitespace (space or tab) #[inline] fn is_whitespace(c: char) -> bool { c == ' ' || c == '\t' } /// Lexer implementation fn lex_impl(input: &str) -> impl Iterator + '_ { let mut remaining = input; let mut at_line_start = true; let mut in_section_header = false; std::iter::from_fn(move || { if remaining.is_empty() { return None; } let c = remaining.chars().next()?; match c { // Newline _ if is_newline(c) => { let char_len = c.len_utf8(); // Handle \r\n as a single newline if c == '\r' && remaining.get(1..2) == Some("\n") { let (token, rest) = remaining.split_at(2); remaining = rest; at_line_start = true; in_section_header = false; Some((SyntaxKind::NEWLINE, token)) } else { let (token, rest) = remaining.split_at(char_len); remaining = rest; at_line_start = true; in_section_header = false; Some((SyntaxKind::NEWLINE, token)) } } // Comment (# or ; at start of line or after whitespace) '#' | ';' if at_line_start => { let end = remaining.find(is_newline).unwrap_or(remaining.len()); let (token, rest) = remaining.split_at(end); remaining = rest; Some((SyntaxKind::COMMENT, token)) } // Line continuation (backslash before newline) '\\' if remaining.get(1..2) == Some("\n") || remaining.get(1..3) == Some("\r\n") => { let len = if remaining.get(1..3) == Some("\r\n") { 3 } else { 2 }; let (token, rest) = remaining.split_at(len); remaining = rest; at_line_start = false; // Line continues, so we're not at the start of a new logical line Some((SyntaxKind::LINE_CONTINUATION, token)) } // Section header [Section Name] '[' if at_line_start => { remaining = &remaining[1..]; // consume '[' at_line_start = false; in_section_header = true; Some((SyntaxKind::LEFT_BRACKET, "[")) } ']' if in_section_header => { remaining = &remaining[1..]; // consume ']' in_section_header = false; Some((SyntaxKind::RIGHT_BRACKET, "]")) } // Whitespace at start of line - could be blank line _ if is_whitespace(c) && at_line_start => { let end = remaining .find(|c| !is_whitespace(c)) .unwrap_or(remaining.len()); let (token, rest) = remaining.split_at(end); remaining = rest; Some((SyntaxKind::WHITESPACE, token)) } // Whitespace (not at line start) _ if is_whitespace(c) => { let end = remaining .find(|c| !is_whitespace(c)) .unwrap_or(remaining.len()); let (token, rest) = remaining.split_at(end); remaining = rest; Some((SyntaxKind::WHITESPACE, token)) } // Equals sign '=' => { remaining = &remaining[1..]; Some((SyntaxKind::EQUALS, "=")) } // Key name (starts with alphabetic) _ if is_valid_initial_key_char(c) && at_line_start => { let end = remaining .find(|c: char| !is_valid_key_char(c)) .unwrap_or(remaining.len()); let (token, rest) = remaining.split_at(end); remaining = rest; at_line_start = false; Some((SyntaxKind::KEY, token)) } // Section name (between [ and ]) _ if in_section_header => { // Inside brackets - read until ] let end = remaining.find(']').unwrap_or(remaining.len()); let (token, rest) = remaining.split_at(end); remaining = rest; Some((SyntaxKind::SECTION_NAME, token)) } // Value (everything else on a line, handling line continuations) _ if !at_line_start => { // Read until newline (but watch for line continuations) let mut end = 0; for ch in remaining.chars() { if ch == '\\' { // Check if it's a line continuation let remaining_from_here = &remaining[end..]; if remaining_from_here.get(1..2) == Some("\n") || remaining_from_here.get(1..3) == Some("\r\n") { // It's a line continuation, stop here break; } end += ch.len_utf8(); } else if is_newline(ch) { // Stop at newline break; } else { end += ch.len_utf8(); } } if end == 0 { // No value content, this shouldn't happen None } else { let (token, rest) = remaining.split_at(end); remaining = rest; Some((SyntaxKind::VALUE, token)) } } // Error: unexpected character at line start _ => { let char_len = c.len_utf8(); let (token, rest) = remaining.split_at(char_len); remaining = rest; at_line_start = false; Some((SyntaxKind::ERROR, token)) } } }) } /// Lex a systemd unit file into tokens pub(crate) fn lex(input: &str) -> impl Iterator { lex_impl(input) } #[cfg(test)] mod tests { use super::SyntaxKind::*; use super::*; #[test] fn test_empty() { assert_eq!(lex("").collect::>(), vec![]); } #[test] fn test_simple_section() { let input = "[Unit]\n"; assert_eq!( lex(input).collect::>(), vec![ (LEFT_BRACKET, "["), (SECTION_NAME, "Unit"), (RIGHT_BRACKET, "]"), (NEWLINE, "\n"), ] ); } #[test] fn test_key_value() { let input = "Description=Test Service\n"; assert_eq!( lex(input).collect::>(), vec![ (KEY, "Description"), (EQUALS, "="), (VALUE, "Test Service"), (NEWLINE, "\n"), ] ); } #[test] fn test_key_value_with_spaces() { let input = "Description = Test Service\n"; assert_eq!( lex(input).collect::>(), vec![ (KEY, "Description"), (WHITESPACE, " "), (EQUALS, "="), (WHITESPACE, " "), (VALUE, "Test Service"), (NEWLINE, "\n"), ] ); } #[test] fn test_comment_hash() { let input = "# This is a comment\n"; assert_eq!( lex(input).collect::>(), vec![(COMMENT, "# This is a comment"), (NEWLINE, "\n"),] ); } #[test] fn test_comment_semicolon() { let input = "; This is a comment\n"; assert_eq!( lex(input).collect::>(), vec![(COMMENT, "; This is a comment"), (NEWLINE, "\n"),] ); } #[test] fn test_line_continuation() { let input = "ExecStart=/bin/echo \\\n hello\n"; let tokens: Vec<_> = lex(input).collect(); assert_eq!(tokens[0], (KEY, "ExecStart")); assert_eq!(tokens[1], (EQUALS, "=")); assert_eq!(tokens[2], (VALUE, "/bin/echo ")); assert_eq!(tokens[3], (LINE_CONTINUATION, "\\\n")); assert_eq!(tokens[4], (WHITESPACE, " ")); assert_eq!(tokens[5], (VALUE, "hello")); assert_eq!(tokens[6], (NEWLINE, "\n")); } #[test] fn test_full_unit_file() { let input = r#"[Unit] Description=Test Service After=network.target [Service] Type=simple ExecStart=/usr/bin/test "#; let tokens: Vec<_> = lex(input).collect(); // Verify we get the expected token types assert_eq!(tokens[0].0, LEFT_BRACKET); assert_eq!(tokens[1].0, SECTION_NAME); assert_eq!(tokens[1].1, "Unit"); assert_eq!(tokens[2].0, RIGHT_BRACKET); assert_eq!(tokens[3].0, NEWLINE); // Find "Description=Test Service" let desc_idx = tokens .iter() .position(|(k, t)| *k == KEY && *t == "Description") .unwrap(); assert_eq!(tokens[desc_idx + 1].0, EQUALS); assert_eq!(tokens[desc_idx + 2].0, VALUE); assert_eq!(tokens[desc_idx + 2].1, "Test Service"); } #[test] fn test_blank_lines() { let input = "Key=Value\n\nKey2=Value2\n"; let tokens: Vec<_> = lex(input).collect(); // Should have two newlines in sequence let first_newline = tokens.iter().position(|(k, _)| *k == NEWLINE).unwrap(); assert_eq!(tokens[first_newline + 1].0, NEWLINE); } } systemd-unit-edit-0.1.1/src/lib.rs000064400000000000000000000021021046102023000151110ustar 00000000000000#![deny(missing_docs)] #![allow(clippy::type_complexity)] #![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] //! A lossless systemd unit file parser and editor. //! //! This library provides a lossless parser for systemd unit files as specified //! by the [systemd.syntax(7)](https://www.freedesktop.org/software/systemd/man/latest/systemd.syntax.html) //! and [systemd.unit(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html). //! It preserves all whitespace, comments, and formatting. //! It is based on the [rowan] library. mod lex; mod parse; mod unit; /// Drop-in directory support mod dropin; /// Systemd specifier expansion pub mod specifier; /// Systemd time span parsing pub mod timespan; /// Systemd-specific metadata and domain knowledge pub mod systemd_metadata; pub use lex::SyntaxKind; pub use parse::Parse; pub use rowan::TextRange; pub use specifier::SpecifierContext; pub use timespan::{parse_timespan, TimespanParseError}; pub use unit::{Entry, Error, Lang, ParseError, PositionedParseError, Section, SystemdUnit}; systemd-unit-edit-0.1.1/src/parse.rs000064400000000000000000000100641046102023000154630ustar 00000000000000//! Parse wrapper type following rust-analyzer's pattern for thread-safe storage in Salsa. use crate::unit::{ParseError, PositionedParseError, SystemdUnit}; use rowan::ast::AstNode; use rowan::{GreenNode, SyntaxNode}; use std::marker::PhantomData; /// The result of parsing: a syntax tree and a collection of errors. /// /// This type is designed to be stored in Salsa databases as it contains /// the thread-safe `GreenNode` instead of the non-thread-safe `SyntaxNode`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Parse { green: GreenNode, errors: Vec, positioned_errors: Vec, _ty: PhantomData, } impl Parse { /// Create a new Parse result from a GreenNode and errors pub fn new(green: GreenNode, errors: Vec) -> Self { Parse { green, errors, positioned_errors: Vec::new(), _ty: PhantomData, } } /// Create a new Parse result from a GreenNode, errors, and positioned errors pub fn new_with_positioned_errors( green: GreenNode, errors: Vec, positioned_errors: Vec, ) -> Self { Parse { green, errors, positioned_errors, _ty: PhantomData, } } /// Get the green node (thread-safe representation) pub fn green(&self) -> &GreenNode { &self.green } /// Get the syntax errors pub fn errors(&self) -> &[String] { &self.errors } /// Get parse errors with position information pub fn positioned_errors(&self) -> &[PositionedParseError] { &self.positioned_errors } /// Get parse errors as strings (for backward compatibility if needed) pub fn error_messages(&self) -> Vec { self.positioned_errors .iter() .map(|e| e.message.clone()) .collect() } /// Check if there are any errors pub fn ok(&self) -> bool { self.errors.is_empty() } /// Convert to a Result, returning the tree if there are no errors pub fn to_result(self) -> Result where T: AstNode, { if self.errors.is_empty() { let node = SyntaxNode::new_root_mut(self.green); Ok(T::cast(node).expect("root node has wrong type")) } else { Err(ParseError(self.errors)) } } /// Get the parsed syntax tree, panicking if there are errors pub fn tree(&self) -> T where T: AstNode, { assert!( self.errors.is_empty(), "tried to get tree with errors: {:?}", self.errors ); let node = SyntaxNode::new_root_mut(self.green.clone()); T::cast(node).expect("root node has wrong type") } /// Get the syntax node pub fn syntax_node(&self) -> SyntaxNode { SyntaxNode::new_root_mut(self.green.clone()) } } // Implement Send + Sync since GreenNode is thread-safe unsafe impl Send for Parse {} unsafe impl Sync for Parse {} impl Parse { /// Parse systemd unit text, returning a Parse result pub fn parse_unit(text: &str) -> Self { let parsed = crate::unit::parse(text); Parse::new_with_positioned_errors( parsed.green_node, parsed.errors, parsed.positioned_errors, ) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_success() { let input = "[Unit]\nDescription=Test\n"; let parsed = Parse::::parse_unit(input); assert!(parsed.ok()); assert!(parsed.errors().is_empty()); let unit = parsed.tree(); assert_eq!(unit.sections().count(), 1); } #[test] fn test_parse_with_errors() { let input = "Invalid line without section\n[Unit]\n"; let parsed = Parse::::parse_unit(input); assert!(!parsed.ok()); assert!(!parsed.errors().is_empty()); } } systemd-unit-edit-0.1.1/src/specifier.rs000064400000000000000000000152541046102023000163300ustar 00000000000000//! Systemd specifier expansion support //! //! Systemd uses percent-escaped specifiers in unit files that are expanded //! at runtime. This module provides functionality to expand these specifiers. use std::collections::HashMap; /// Context for expanding systemd specifiers /// /// This contains the values that will be substituted when expanding /// specifiers in unit file values. #[derive(Debug, Clone, Default)] pub struct SpecifierContext { values: HashMap, } impl SpecifierContext { /// Create a new empty specifier context pub fn new() -> Self { Self { values: HashMap::new(), } } /// Set a specifier value /// /// # Example /// /// ``` /// # use systemd_unit_edit::SpecifierContext; /// let mut ctx = SpecifierContext::new(); /// ctx.set("i", "instance"); /// ctx.set("u", "user"); /// ``` pub fn set(&mut self, specifier: &str, value: &str) { self.values.insert(specifier.to_string(), value.to_string()); } /// Get a specifier value pub fn get(&self, specifier: &str) -> Option<&str> { self.values.get(specifier).map(|s| s.as_str()) } /// Create a context with common system specifiers /// /// This sets up commonly used specifiers with their values: /// - `%n`: Unit name (without type suffix) /// - `%N`: Full unit name /// - `%p`: Prefix (for template units) /// - `%i`: Instance (for template units) /// /// # Example /// /// ``` /// # use systemd_unit_edit::SpecifierContext; /// let ctx = SpecifierContext::with_unit_name("foo@bar.service"); /// assert_eq!(ctx.get("N"), Some("foo@bar.service")); /// assert_eq!(ctx.get("n"), Some("foo@bar")); /// assert_eq!(ctx.get("p"), Some("foo")); /// assert_eq!(ctx.get("i"), Some("bar")); /// ``` pub fn with_unit_name(unit_name: &str) -> Self { let mut ctx = Self::new(); // Full unit name ctx.set("N", unit_name); // Unit name without suffix let name_without_suffix = unit_name .rsplit_once('.') .map(|(name, _)| name) .unwrap_or(unit_name); ctx.set("n", name_without_suffix); // For template units (foo@instance.service) if let Some((prefix, instance_with_suffix)) = name_without_suffix.split_once('@') { ctx.set("p", prefix); ctx.set("i", instance_with_suffix); } ctx } /// Expand specifiers in a string /// /// This replaces all `%X` patterns with their corresponding values from the context. /// `%%` is replaced with a single `%`. /// /// # Example /// /// ``` /// # use systemd_unit_edit::SpecifierContext; /// let mut ctx = SpecifierContext::new(); /// ctx.set("i", "myinstance"); /// ctx.set("u", "myuser"); /// /// let result = ctx.expand("/var/lib/%i/data/%u"); /// assert_eq!(result, "/var/lib/myinstance/data/myuser"); /// ``` pub fn expand(&self, input: &str) -> String { let mut result = String::new(); let mut chars = input.chars().peekable(); while let Some(ch) = chars.next() { if ch == '%' { if let Some(&next) = chars.peek() { chars.next(); // consume the peeked character if next == '%' { // %% -> % result.push('%'); } else { // %X -> lookup let specifier = next.to_string(); if let Some(value) = self.get(&specifier) { result.push_str(value); } else { // Unknown specifier, keep as-is result.push('%'); result.push(next); } } } else { // % at end of string result.push('%'); } } else { result.push(ch); } } result } } #[cfg(test)] mod tests { use super::*; #[test] fn test_basic_expansion() { let mut ctx = SpecifierContext::new(); ctx.set("i", "instance"); ctx.set("u", "user"); assert_eq!(ctx.expand("Hello %i"), "Hello instance"); assert_eq!(ctx.expand("%u@%i"), "user@instance"); assert_eq!(ctx.expand("/home/%u/%i"), "/home/user/instance"); } #[test] fn test_percent_escape() { let ctx = SpecifierContext::new(); assert_eq!(ctx.expand("100%% complete"), "100% complete"); assert_eq!(ctx.expand("%%u"), "%u"); } #[test] fn test_unknown_specifier() { let ctx = SpecifierContext::new(); // Unknown specifiers are kept as-is assert_eq!(ctx.expand("%x"), "%x"); assert_eq!(ctx.expand("test %z end"), "test %z end"); } #[test] fn test_percent_at_end() { let ctx = SpecifierContext::new(); assert_eq!(ctx.expand("test%"), "test%"); } #[test] fn test_with_unit_name_simple() { let ctx = SpecifierContext::with_unit_name("foo.service"); assert_eq!(ctx.get("N"), Some("foo.service")); assert_eq!(ctx.get("n"), Some("foo")); assert_eq!(ctx.get("p"), None); assert_eq!(ctx.get("i"), None); } #[test] fn test_with_unit_name_template() { let ctx = SpecifierContext::with_unit_name("foo@bar.service"); assert_eq!(ctx.get("N"), Some("foo@bar.service")); assert_eq!(ctx.get("n"), Some("foo@bar")); assert_eq!(ctx.get("p"), Some("foo")); assert_eq!(ctx.get("i"), Some("bar")); assert_eq!(ctx.expand("Unit %N"), "Unit foo@bar.service"); assert_eq!(ctx.expand("Prefix %p"), "Prefix foo"); assert_eq!(ctx.expand("Instance %i"), "Instance bar"); } #[test] fn test_with_unit_name_complex_instance() { let ctx = SpecifierContext::with_unit_name("getty@tty1.service"); assert_eq!(ctx.get("p"), Some("getty")); assert_eq!(ctx.get("i"), Some("tty1")); assert_eq!(ctx.expand("/dev/%i"), "/dev/tty1"); } #[test] fn test_multiple_specifiers() { let mut ctx = SpecifierContext::new(); ctx.set("i", "inst"); ctx.set("u", "usr"); ctx.set("h", "/home/usr"); assert_eq!( ctx.expand("%h/.config/%i/data"), "/home/usr/.config/inst/data" ); } #[test] fn test_no_specifiers() { let ctx = SpecifierContext::new(); assert_eq!(ctx.expand("plain text"), "plain text"); assert_eq!(ctx.expand("/etc/config"), "/etc/config"); } } systemd-unit-edit-0.1.1/src/systemd_metadata.rs000064400000000000000000000066421046102023000177100ustar 00000000000000//! Systemd-specific metadata and domain knowledge //! //! This module contains information about systemd directives, their types, //! and how they should be merged in drop-in files. /// Check if a directive is accumulating (values should be added rather than replaced) /// /// In systemd, some directives accumulate values across drop-ins (like Wants=, After=), /// while others replace (like Description=, Type=). /// /// This is based on systemd's behavior where certain directives are list-based /// and should accumulate when multiple values are specified across the main unit /// and drop-in files. pub fn is_accumulating_directive(key: &str) -> bool { matches!( key, // Unit dependencies and ordering "Wants" | "Requires" | "Requisite" | "BindsTo" | "PartOf" | "Upholds" | "After" | "Before" | "Conflicts" | "OnFailure" | "OnSuccess" | "PropagatesReloadTo" | "ReloadPropagatedFrom" | "PropagatesStopTo" | "StopPropagatedFrom" | "JoinsNamespaceOf" | "RequiresMountsFor" | "OnSuccessJobMode" | "OnFailureJobMode" // Environment | "Environment" | "EnvironmentFile" | "PassEnvironment" | "UnsetEnvironment" // Execution | "ExecStartPre" | "ExecStartPost" | "ExecCondition" | "ExecReload" | "ExecStop" | "ExecStopPost" // Groups and users | "SupplementaryGroups" // Paths and security | "ReadWritePaths" | "ReadOnlyPaths" | "InaccessiblePaths" | "ExecPaths" | "NoExecPaths" | "ExecSearchPath" | "LogExtraFields" | "RestrictAddressFamilies" | "SystemCallFilter" | "SystemCallLog" | "SystemCallArchitectures" | "RestrictNetworkInterfaces" | "BindPaths" | "BindReadOnlyPaths" // Device access | "DeviceAllow" // Sockets | "ListenStream" | "ListenDatagram" | "ListenSequentialPacket" | "ListenFIFO" | "ListenSpecial" | "ListenNetlink" | "ListenMessageQueue" | "ListenUSBFunction" // Path | "PathExists" | "PathExistsGlob" | "PathChanged" | "PathModified" | "DirectoryNotEmpty" // Timer | "OnActiveSec" | "OnBootSec" | "OnStartupSec" | "OnUnitActiveSec" | "OnUnitInactiveSec" | "OnCalendar" ) } #[cfg(test)] mod tests { use super::*; #[test] fn test_accumulating_directives() { assert!(is_accumulating_directive("Wants")); assert!(is_accumulating_directive("After")); assert!(is_accumulating_directive("Requires")); assert!(is_accumulating_directive("Environment")); } #[test] fn test_non_accumulating_directives() { assert!(!is_accumulating_directive("Description")); assert!(!is_accumulating_directive("Type")); assert!(!is_accumulating_directive("ExecStart")); assert!(!is_accumulating_directive("User")); } } systemd-unit-edit-0.1.1/src/timespan.rs000064400000000000000000000217421046102023000161760ustar 00000000000000//! Systemd time span parsing //! //! This module provides functionality to parse systemd time span values //! as used in directives like `RuntimeMaxSec=`, `TimeoutStartSec=`, etc. use std::time::Duration; /// Parse a systemd time span string into a Duration /// /// Systemd accepts time spans in the following formats: /// - Plain numbers are interpreted as seconds (e.g., `30` = 30 seconds) /// - Numbers with units: `s` (seconds), `min` (minutes), `h` (hours), /// `d` (days), `w` (weeks), `ms` (milliseconds), `us` (microseconds) /// - Multiple values can be combined additively: `2min 30s` = 150 seconds /// - Whitespace between values is optional: `2min30s` = 150 seconds /// /// # Example /// /// ``` /// # use systemd_unit_edit::parse_timespan; /// # use std::time::Duration; /// assert_eq!(parse_timespan("30"), Ok(Duration::from_secs(30))); /// assert_eq!(parse_timespan("2min"), Ok(Duration::from_secs(120))); /// assert_eq!(parse_timespan("1h 30min"), Ok(Duration::from_secs(5400))); /// assert_eq!(parse_timespan("2min 30s"), Ok(Duration::from_millis(150_000))); /// ``` pub fn parse_timespan(s: &str) -> Result { let s = s.trim(); if s.is_empty() { return Err(TimespanParseError::Empty); } let mut total_micros: u128 = 0; let mut current_number = String::new(); let mut chars = s.chars().peekable(); while let Some(ch) = chars.next() { if ch.is_ascii_digit() { current_number.push(ch); } else if ch.is_whitespace() { // Whitespace can separate values or be between number and unit if !current_number.is_empty() { // Check if next is a unit or another number if let Some(&next) = chars.peek() { if next.is_ascii_alphabetic() { // Continue to unit parsing continue; } else if next.is_ascii_digit() { // Number followed by whitespace and another number means default unit (seconds) let value: u64 = current_number .parse() .map_err(|_| TimespanParseError::InvalidNumber)?; total_micros += value as u128 * 1_000_000; current_number.clear(); } } else { // Number at end with no unit = seconds let value: u64 = current_number .parse() .map_err(|_| TimespanParseError::InvalidNumber)?; total_micros += value as u128 * 1_000_000; current_number.clear(); } } } else if ch.is_ascii_alphabetic() { // Parse unit let mut unit = String::from(ch); while let Some(&next) = chars.peek() { if next.is_ascii_alphabetic() { unit.push(chars.next().unwrap()); } else { break; } } if current_number.is_empty() { return Err(TimespanParseError::MissingNumber); } let value: u64 = current_number .parse() .map_err(|_| TimespanParseError::InvalidNumber)?; let micros = match unit.as_str() { "us" | "usec" => value as u128, "ms" | "msec" => value as u128 * 1_000, "s" | "sec" | "second" | "seconds" => value as u128 * 1_000_000, "min" | "minute" | "minutes" => value as u128 * 60 * 1_000_000, "h" | "hr" | "hour" | "hours" => value as u128 * 60 * 60 * 1_000_000, "d" | "day" | "days" => value as u128 * 24 * 60 * 60 * 1_000_000, "w" | "week" | "weeks" => value as u128 * 7 * 24 * 60 * 60 * 1_000_000, _ => return Err(TimespanParseError::InvalidUnit(unit)), }; total_micros += micros; current_number.clear(); } else { return Err(TimespanParseError::InvalidCharacter(ch)); } } // Handle remaining number (no unit = seconds) if !current_number.is_empty() { let value: u64 = current_number .parse() .map_err(|_| TimespanParseError::InvalidNumber)?; total_micros += value as u128 * 1_000_000; } if total_micros == 0 { return Err(TimespanParseError::Empty); } // Convert microseconds to Duration let secs = (total_micros / 1_000_000) as u64; let nanos = ((total_micros % 1_000_000) * 1_000) as u32; Ok(Duration::new(secs, nanos)) } /// Error type for timespan parsing #[derive(Debug, Clone, PartialEq, Eq)] pub enum TimespanParseError { /// The input string is empty Empty, /// Invalid number format InvalidNumber, /// Number without a unit MissingNumber, /// Unknown time unit InvalidUnit(String), /// Invalid character in input InvalidCharacter(char), } impl std::fmt::Display for TimespanParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TimespanParseError::Empty => write!(f, "empty timespan"), TimespanParseError::InvalidNumber => write!(f, "invalid number format"), TimespanParseError::MissingNumber => write!(f, "unit specified without a number"), TimespanParseError::InvalidUnit(unit) => write!(f, "invalid time unit: {}", unit), TimespanParseError::InvalidCharacter(ch) => { write!(f, "invalid character: {}", ch) } } } } impl std::error::Error for TimespanParseError {} #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_plain_number() { assert_eq!(parse_timespan("30"), Ok(Duration::from_secs(30))); assert_eq!(parse_timespan("0"), Err(TimespanParseError::Empty)); assert_eq!(parse_timespan("120"), Ok(Duration::from_secs(120))); } #[test] fn test_parse_seconds() { assert_eq!(parse_timespan("30s"), Ok(Duration::from_secs(30))); assert_eq!(parse_timespan("1sec"), Ok(Duration::from_secs(1))); assert_eq!(parse_timespan("5seconds"), Ok(Duration::from_secs(5))); } #[test] fn test_parse_minutes() { assert_eq!(parse_timespan("2min"), Ok(Duration::from_secs(120))); assert_eq!(parse_timespan("1minute"), Ok(Duration::from_secs(60))); assert_eq!(parse_timespan("5minutes"), Ok(Duration::from_secs(300))); } #[test] fn test_parse_hours() { assert_eq!(parse_timespan("1h"), Ok(Duration::from_secs(3600))); assert_eq!(parse_timespan("2hr"), Ok(Duration::from_secs(7200))); assert_eq!(parse_timespan("1hour"), Ok(Duration::from_secs(3600))); assert_eq!(parse_timespan("3hours"), Ok(Duration::from_secs(10800))); } #[test] fn test_parse_days() { assert_eq!(parse_timespan("1d"), Ok(Duration::from_secs(86400))); assert_eq!(parse_timespan("2days"), Ok(Duration::from_secs(172800))); } #[test] fn test_parse_weeks() { assert_eq!(parse_timespan("1w"), Ok(Duration::from_secs(604800))); assert_eq!(parse_timespan("2weeks"), Ok(Duration::from_secs(1209600))); } #[test] fn test_parse_milliseconds() { assert_eq!(parse_timespan("500ms"), Ok(Duration::from_millis(500))); assert_eq!(parse_timespan("1000msec"), Ok(Duration::from_millis(1000))); } #[test] fn test_parse_microseconds() { assert_eq!(parse_timespan("500us"), Ok(Duration::from_micros(500))); assert_eq!(parse_timespan("1000usec"), Ok(Duration::from_micros(1000))); } #[test] fn test_parse_combined() { assert_eq!(parse_timespan("2min 30s"), Ok(Duration::from_secs(150))); assert_eq!(parse_timespan("1h 30min"), Ok(Duration::from_secs(5400))); assert_eq!( parse_timespan("1d 2h 3min 4s"), Ok(Duration::from_secs(93784)) ); } #[test] fn test_parse_combined_no_space() { assert_eq!(parse_timespan("2min30s"), Ok(Duration::from_secs(150))); assert_eq!(parse_timespan("1h30min"), Ok(Duration::from_secs(5400))); } #[test] fn test_parse_with_extra_whitespace() { assert_eq!( parse_timespan(" 2min 30s "), Ok(Duration::from_secs(150)) ); assert_eq!(parse_timespan("1h 30min"), Ok(Duration::from_secs(5400))); } #[test] fn test_parse_errors() { assert_eq!(parse_timespan(""), Err(TimespanParseError::Empty)); assert_eq!(parse_timespan(" "), Err(TimespanParseError::Empty)); assert!(parse_timespan("abc").is_err()); assert!(parse_timespan("10xyz").is_err()); } #[test] fn test_parse_subsecond_precision() { let result = parse_timespan("1s 500ms").unwrap(); assert_eq!(result, Duration::from_millis(1500)); let result = parse_timespan("200ms").unwrap(); assert_eq!(result.as_millis(), 200); } } systemd-unit-edit-0.1.1/src/unit.rs000064400000000000000000001553011046102023000153340ustar 00000000000000//! Parser for systemd unit files. //! //! This parser can be used to parse systemd unit files (as specified //! by the [systemd.syntax(7)](https://www.freedesktop.org/software/systemd/man/latest/systemd.syntax.html)), //! while preserving all whitespace and comments. It is based on //! the [rowan] library, which is a lossless parser library for Rust. //! //! Once parsed, the file can be traversed or modified, and then written back to a file. //! //! # Example //! //! ``` //! use systemd_unit_edit::SystemdUnit; //! use std::str::FromStr; //! //! # let input = r#"[Unit] //! # Description=Test Service //! # After=network.target //! # //! # [Service] //! # Type=simple //! # ExecStart=/usr/bin/test //! # "#; //! # let unit = SystemdUnit::from_str(input).unwrap(); //! # assert_eq!(unit.sections().count(), 2); //! # let section = unit.sections().next().unwrap(); //! # assert_eq!(section.name(), Some("Unit".to_string())); //! ``` use crate::lex::{lex, SyntaxKind}; use rowan::ast::AstNode; use rowan::{GreenNode, GreenNodeBuilder}; use std::path::Path; use std::str::FromStr; /// A positioned parse error containing location information. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PositionedParseError { /// The error message pub message: String, /// The text range where the error occurred pub range: rowan::TextRange, /// Optional error code for categorization pub code: Option, } impl std::fmt::Display for PositionedParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.message) } } impl std::error::Error for PositionedParseError {} /// List of encountered syntax errors. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ParseError(pub Vec); impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { for err in &self.0 { writeln!(f, "{}", err)?; } Ok(()) } } impl std::error::Error for ParseError {} /// Error parsing systemd unit files #[derive(Debug)] pub enum Error { /// A syntax error was encountered while parsing the file. ParseError(ParseError), /// An I/O error was encountered while reading the file. IoError(std::io::Error), } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match &self { Error::ParseError(err) => write!(f, "{}", err), Error::IoError(err) => write!(f, "{}", err), } } } impl From for Error { fn from(err: ParseError) -> Self { Self::ParseError(err) } } impl From for Error { fn from(err: std::io::Error) -> Self { Self::IoError(err) } } impl std::error::Error for Error {} /// Language definition for rowan #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Lang {} impl rowan::Language for Lang { type Kind = SyntaxKind; fn kind_from_raw(raw: rowan::SyntaxKind) -> Self::Kind { unsafe { std::mem::transmute::(raw.0) } } fn kind_to_raw(kind: Self::Kind) -> rowan::SyntaxKind { kind.into() } } /// Internal parse result pub(crate) struct ParseResult { pub(crate) green_node: GreenNode, pub(crate) errors: Vec, pub(crate) positioned_errors: Vec, } /// Parse a systemd unit file pub(crate) fn parse(text: &str) -> ParseResult { struct Parser<'a> { tokens: Vec<(SyntaxKind, &'a str)>, builder: GreenNodeBuilder<'static>, errors: Vec, positioned_errors: Vec, pos: usize, } impl<'a> Parser<'a> { fn current(&self) -> Option { if self.pos < self.tokens.len() { Some(self.tokens[self.tokens.len() - 1 - self.pos].0) } else { None } } fn bump(&mut self) { if self.pos < self.tokens.len() { let (kind, text) = self.tokens[self.tokens.len() - 1 - self.pos]; self.builder.token(kind.into(), text); self.pos += 1; } } fn skip_ws(&mut self) { while self.current() == Some(SyntaxKind::WHITESPACE) { self.bump(); } } fn skip_blank_lines(&mut self) { while let Some(kind) = self.current() { match kind { SyntaxKind::NEWLINE => { self.builder.start_node(SyntaxKind::BLANK_LINE.into()); self.bump(); self.builder.finish_node(); } SyntaxKind::WHITESPACE => { // Check if followed by newline if self.pos + 1 < self.tokens.len() && self.tokens[self.tokens.len() - 2 - self.pos].0 == SyntaxKind::NEWLINE { self.builder.start_node(SyntaxKind::BLANK_LINE.into()); self.bump(); // whitespace self.bump(); // newline self.builder.finish_node(); } else { break; } } _ => break, } } } fn parse_section_header(&mut self) { self.builder.start_node(SyntaxKind::SECTION_HEADER.into()); // Consume '[' if self.current() == Some(SyntaxKind::LEFT_BRACKET) { self.bump(); } else { self.errors .push("expected '[' at start of section header".to_string()); } // Consume section name if self.current() == Some(SyntaxKind::SECTION_NAME) { self.bump(); } else { self.errors .push("expected section name in section header".to_string()); } // Consume ']' if self.current() == Some(SyntaxKind::RIGHT_BRACKET) { self.bump(); } else { self.errors .push("expected ']' at end of section header".to_string()); } // Consume newline if present if self.current() == Some(SyntaxKind::NEWLINE) { self.bump(); } self.builder.finish_node(); } fn parse_entry(&mut self) { self.builder.start_node(SyntaxKind::ENTRY.into()); // Handle comment before entry if self.current() == Some(SyntaxKind::COMMENT) { self.bump(); if self.current() == Some(SyntaxKind::NEWLINE) { self.bump(); } self.builder.finish_node(); return; } // Parse key if self.current() == Some(SyntaxKind::KEY) { self.bump(); } else { self.errors .push(format!("expected key, got {:?}", self.current())); } self.skip_ws(); // Parse '=' if self.current() == Some(SyntaxKind::EQUALS) { self.bump(); } else { self.errors.push("expected '=' after key".to_string()); } self.skip_ws(); // Parse value (may include line continuations) while let Some(kind) = self.current() { match kind { SyntaxKind::VALUE => self.bump(), SyntaxKind::LINE_CONTINUATION => { self.bump(); // After line continuation, skip leading whitespace self.skip_ws(); } SyntaxKind::NEWLINE => { self.bump(); break; } _ => break, } } self.builder.finish_node(); } fn parse_section(&mut self) { self.builder.start_node(SyntaxKind::SECTION.into()); // Parse section header self.parse_section_header(); // Parse entries until we hit another section header or EOF while let Some(kind) = self.current() { match kind { SyntaxKind::LEFT_BRACKET => break, // Start of next section SyntaxKind::KEY | SyntaxKind::COMMENT => self.parse_entry(), SyntaxKind::NEWLINE | SyntaxKind::WHITESPACE => { self.skip_blank_lines(); } _ => { self.errors .push(format!("unexpected token in section: {:?}", kind)); self.bump(); } } } self.builder.finish_node(); } fn parse_file(&mut self) { self.builder.start_node(SyntaxKind::ROOT.into()); // Skip leading blank lines and comments while let Some(kind) = self.current() { match kind { SyntaxKind::COMMENT => { self.builder.start_node(SyntaxKind::ENTRY.into()); self.bump(); if self.current() == Some(SyntaxKind::NEWLINE) { self.bump(); } self.builder.finish_node(); } SyntaxKind::NEWLINE | SyntaxKind::WHITESPACE => { self.skip_blank_lines(); } _ => break, } } // Parse sections while self.current().is_some() { if self.current() == Some(SyntaxKind::LEFT_BRACKET) { self.parse_section(); } else { self.errors .push(format!("expected section header, got {:?}", self.current())); self.bump(); } } self.builder.finish_node(); } } let mut tokens: Vec<_> = lex(text).collect(); tokens.reverse(); let mut parser = Parser { tokens, builder: GreenNodeBuilder::new(), errors: Vec::new(), positioned_errors: Vec::new(), pos: 0, }; parser.parse_file(); ParseResult { green_node: parser.builder.finish(), errors: parser.errors, positioned_errors: parser.positioned_errors, } } // Type aliases for convenience type SyntaxNode = rowan::SyntaxNode; /// The root of a systemd unit file #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct SystemdUnit(SyntaxNode); impl SystemdUnit { /// Get all sections in the file pub fn sections(&self) -> impl Iterator { self.0.children().filter_map(Section::cast) } /// Get a specific section by name pub fn get_section(&self, name: &str) -> Option
{ self.sections().find(|s| s.name().as_deref() == Some(name)) } /// Add a new section to the unit file pub fn add_section(&mut self, name: &str) { let new_section = Section::new(name); let insertion_index = self.0.children_with_tokens().count(); self.0 .splice_children(insertion_index..insertion_index, vec![new_section.0.into()]); } /// Get the raw syntax node pub fn syntax(&self) -> &SyntaxNode { &self.0 } /// Convert to a string (same as Display::fmt) pub fn text(&self) -> String { self.0.text().to_string() } /// Load from a file pub fn from_file(path: &Path) -> Result { let text = std::fs::read_to_string(path)?; Self::from_str(&text) } /// Write to a file pub fn write_to_file(&self, path: &Path) -> Result<(), Error> { std::fs::write(path, self.text())?; Ok(()) } } impl AstNode for SystemdUnit { type Language = Lang; fn can_cast(kind: SyntaxKind) -> bool { kind == SyntaxKind::ROOT } fn cast(node: SyntaxNode) -> Option { if node.kind() == SyntaxKind::ROOT { Some(SystemdUnit(node)) } else { None } } fn syntax(&self) -> &SyntaxNode { &self.0 } } impl FromStr for SystemdUnit { type Err = Error; fn from_str(s: &str) -> Result { let parsed = parse(s); if !parsed.errors.is_empty() { return Err(Error::ParseError(ParseError(parsed.errors))); } let node = SyntaxNode::new_root_mut(parsed.green_node); Ok(SystemdUnit::cast(node).expect("root node should be SystemdUnit")) } } impl std::fmt::Display for SystemdUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0.text()) } } /// A section in a systemd unit file (e.g., [Unit], [Service]) #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Section(SyntaxNode); impl Section { /// Create a new section with the given name pub fn new(name: &str) -> Section { use rowan::GreenNodeBuilder; let mut builder = GreenNodeBuilder::new(); builder.start_node(SyntaxKind::SECTION.into()); // Build section header builder.start_node(SyntaxKind::SECTION_HEADER.into()); builder.token(SyntaxKind::LEFT_BRACKET.into(), "["); builder.token(SyntaxKind::SECTION_NAME.into(), name); builder.token(SyntaxKind::RIGHT_BRACKET.into(), "]"); builder.token(SyntaxKind::NEWLINE.into(), "\n"); builder.finish_node(); builder.finish_node(); Section(SyntaxNode::new_root_mut(builder.finish())) } /// Get the name of the section pub fn name(&self) -> Option { let header = self .0 .children() .find(|n| n.kind() == SyntaxKind::SECTION_HEADER)?; let value = header .children_with_tokens() .find(|e| e.kind() == SyntaxKind::SECTION_NAME)?; Some(value.as_token()?.text().to_string()) } /// Get all entries in the section pub fn entries(&self) -> impl Iterator { self.0.children().filter_map(Entry::cast) } /// Get a specific entry by key pub fn get(&self, key: &str) -> Option { self.entries() .find(|e| e.key().as_deref() == Some(key)) .and_then(|e| e.value()) } /// Get all values for a key (systemd allows multiple entries with the same key) pub fn get_all(&self, key: &str) -> Vec { self.entries() .filter(|e| e.key().as_deref() == Some(key)) .filter_map(|e| e.value()) .collect() } /// Set a value for a key (replaces the first occurrence or adds if it doesn't exist) pub fn set(&mut self, key: &str, value: &str) { let new_entry = Entry::new(key, value); // Check if the field already exists and replace the first occurrence for entry in self.entries() { if entry.key().as_deref() == Some(key) { self.0.splice_children( entry.0.index()..entry.0.index() + 1, vec![new_entry.0.into()], ); return; } } // Field doesn't exist, append at the end (before trailing whitespace) let children: Vec<_> = self.0.children_with_tokens().collect(); let insertion_index = children .iter() .enumerate() .rev() .find(|(_, child)| { child.kind() != SyntaxKind::BLANK_LINE && child.kind() != SyntaxKind::NEWLINE && child.kind() != SyntaxKind::WHITESPACE }) .map(|(idx, _)| idx + 1) .unwrap_or(children.len()); self.0 .splice_children(insertion_index..insertion_index, vec![new_entry.0.into()]); } /// Add a value for a key (appends even if the key already exists) pub fn add(&mut self, key: &str, value: &str) { let new_entry = Entry::new(key, value); // Find the last non-whitespace child to insert after let children: Vec<_> = self.0.children_with_tokens().collect(); let insertion_index = children .iter() .enumerate() .rev() .find(|(_, child)| { child.kind() != SyntaxKind::BLANK_LINE && child.kind() != SyntaxKind::NEWLINE && child.kind() != SyntaxKind::WHITESPACE }) .map(|(idx, _)| idx + 1) .unwrap_or(children.len()); self.0 .splice_children(insertion_index..insertion_index, vec![new_entry.0.into()]); } /// Set a space-separated list value for a key /// /// This is a convenience method for setting list-type directives /// (e.g., `Wants=`, `After=`). The values will be joined with spaces. /// /// # Example /// /// ``` /// # use systemd_unit_edit::SystemdUnit; /// # use std::str::FromStr; /// # let mut unit = SystemdUnit::from_str("[Unit]\n").unwrap(); /// # let mut section = unit.get_section("Unit").unwrap(); /// section.set_list("Wants", &["foo.service", "bar.service"]); /// // Results in: Wants=foo.service bar.service /// ``` pub fn set_list(&mut self, key: &str, values: &[&str]) { let value = values.join(" "); self.set(key, &value); } /// Get a value parsed as a space-separated list /// /// This is a convenience method for getting list-type directives. /// If the key doesn't exist, returns an empty vector. pub fn get_list(&self, key: &str) -> Vec { self.entries() .find(|e| e.key().as_deref() == Some(key)) .map(|e| e.value_as_list()) .unwrap_or_default() } /// Get a value parsed as a boolean /// /// Returns `None` if the key doesn't exist or if the value is not a valid boolean. /// /// # Example /// /// ``` /// # use systemd_unit_edit::SystemdUnit; /// # use std::str::FromStr; /// let unit = SystemdUnit::from_str("[Service]\nRemainAfterExit=yes\n").unwrap(); /// let section = unit.get_section("Service").unwrap(); /// assert_eq!(section.get_bool("RemainAfterExit"), Some(true)); /// ``` pub fn get_bool(&self, key: &str) -> Option { self.entries() .find(|e| e.key().as_deref() == Some(key)) .and_then(|e| e.value_as_bool()) } /// Set a boolean value for a key /// /// This is a convenience method that formats the boolean as "yes" or "no". /// /// # Example /// /// ``` /// # use systemd_unit_edit::SystemdUnit; /// # use std::str::FromStr; /// let unit = SystemdUnit::from_str("[Service]\n").unwrap(); /// let mut section = unit.get_section("Service").unwrap(); /// section.set_bool("RemainAfterExit", true); /// assert_eq!(section.get("RemainAfterExit"), Some("yes".to_string())); /// ``` pub fn set_bool(&mut self, key: &str, value: bool) { self.set(key, Entry::format_bool(value)); } /// Remove the first entry with the given key pub fn remove(&mut self, key: &str) { // Find and remove the first entry with the matching key let entry_to_remove = self.0.children().find_map(|child| { let entry = Entry::cast(child)?; if entry.key().as_deref() == Some(key) { Some(entry) } else { None } }); if let Some(entry) = entry_to_remove { entry.syntax().detach(); } } /// Remove all entries with the given key pub fn remove_all(&mut self, key: &str) { // Collect all entries to remove first (can't mutate while iterating) let entries_to_remove: Vec<_> = self .0 .children() .filter_map(Entry::cast) .filter(|e| e.key().as_deref() == Some(key)) .collect(); for entry in entries_to_remove { entry.syntax().detach(); } } /// Get the raw syntax node pub fn syntax(&self) -> &SyntaxNode { &self.0 } } impl AstNode for Section { type Language = Lang; fn can_cast(kind: SyntaxKind) -> bool { kind == SyntaxKind::SECTION } fn cast(node: SyntaxNode) -> Option { if node.kind() == SyntaxKind::SECTION { Some(Section(node)) } else { None } } fn syntax(&self) -> &SyntaxNode { &self.0 } } /// Unescape a string by processing C-style escape sequences fn unescape_string(s: &str) -> String { let mut result = String::new(); let mut chars = s.chars().peekable(); while let Some(ch) = chars.next() { if ch == '\\' { match chars.next() { Some('n') => result.push('\n'), Some('t') => result.push('\t'), Some('r') => result.push('\r'), Some('\\') => result.push('\\'), Some('"') => result.push('"'), Some('\'') => result.push('\''), Some('x') => { // Hexadecimal byte: \xhh let hex: String = chars.by_ref().take(2).collect(); if let Ok(byte) = u8::from_str_radix(&hex, 16) { result.push(byte as char); } else { // Invalid escape, keep as-is result.push('\\'); result.push('x'); result.push_str(&hex); } } Some('u') => { // Unicode codepoint: \unnnn let hex: String = chars.by_ref().take(4).collect(); if let Ok(code) = u32::from_str_radix(&hex, 16) { if let Some(unicode_char) = char::from_u32(code) { result.push(unicode_char); } else { // Invalid codepoint, keep as-is result.push('\\'); result.push('u'); result.push_str(&hex); } } else { // Invalid escape, keep as-is result.push('\\'); result.push('u'); result.push_str(&hex); } } Some('U') => { // Unicode codepoint: \Unnnnnnnn let hex: String = chars.by_ref().take(8).collect(); if let Ok(code) = u32::from_str_radix(&hex, 16) { if let Some(unicode_char) = char::from_u32(code) { result.push(unicode_char); } else { // Invalid codepoint, keep as-is result.push('\\'); result.push('U'); result.push_str(&hex); } } else { // Invalid escape, keep as-is result.push('\\'); result.push('U'); result.push_str(&hex); } } Some(c) if c.is_ascii_digit() => { // Octal byte: \nnn (up to 3 digits) let mut octal = String::from(c); for _ in 0..2 { if let Some(&next_ch) = chars.peek() { if next_ch.is_ascii_digit() && next_ch < '8' { octal.push(chars.next().unwrap()); } else { break; } } } if let Ok(byte) = u8::from_str_radix(&octal, 8) { result.push(byte as char); } else { // Invalid escape, keep as-is result.push('\\'); result.push_str(&octal); } } Some(c) => { // Unknown escape sequence, keep the backslash result.push('\\'); result.push(c); } None => { // Backslash at end of string result.push('\\'); } } } else { result.push(ch); } } result } /// Escape a string for use in systemd unit files fn escape_string(s: &str) -> String { let mut result = String::new(); for ch in s.chars() { match ch { '\\' => result.push_str("\\\\"), '\n' => result.push_str("\\n"), '\t' => result.push_str("\\t"), '\r' => result.push_str("\\r"), '"' => result.push_str("\\\""), _ => result.push(ch), } } result } /// Remove quotes from a string if present /// /// According to systemd specification, quotes (both double and single) are /// removed when processing values. This function handles: /// - Removing matching outer quotes /// - Preserving whitespace inside quotes /// - Handling escaped quotes inside quoted strings fn unquote_string(s: &str) -> String { let trimmed = s.trim(); if trimmed.len() < 2 { return trimmed.to_string(); } let first = trimmed.chars().next(); let last = trimmed.chars().last(); // Check if string is quoted with matching quotes if let (Some('"'), Some('"')) = (first, last) { // Remove outer quotes trimmed[1..trimmed.len() - 1].to_string() } else if let (Some('\''), Some('\'')) = (first, last) { // Remove outer quotes trimmed[1..trimmed.len() - 1].to_string() } else { // Not quoted, return as-is (but trimmed) trimmed.to_string() } } /// A key-value entry in a section #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Entry(SyntaxNode); impl Entry { /// Create a new entry with key=value pub fn new(key: &str, value: &str) -> Entry { use rowan::GreenNodeBuilder; let mut builder = GreenNodeBuilder::new(); builder.start_node(SyntaxKind::ENTRY.into()); builder.token(SyntaxKind::KEY.into(), key); builder.token(SyntaxKind::EQUALS.into(), "="); builder.token(SyntaxKind::VALUE.into(), value); builder.token(SyntaxKind::NEWLINE.into(), "\n"); builder.finish_node(); Entry(SyntaxNode::new_root_mut(builder.finish())) } /// Get the key name pub fn key(&self) -> Option { let key_token = self .0 .children_with_tokens() .find(|e| e.kind() == SyntaxKind::KEY)?; Some(key_token.as_token()?.text().to_string()) } /// Get the value (handles line continuations) pub fn value(&self) -> Option { // Find all VALUE tokens after EQUALS, handling line continuations let mut found_equals = false; let mut value_parts = Vec::new(); for element in self.0.children_with_tokens() { match element.kind() { SyntaxKind::EQUALS => found_equals = true, SyntaxKind::VALUE if found_equals => { value_parts.push(element.as_token()?.text().to_string()); } SyntaxKind::LINE_CONTINUATION if found_equals => { // Line continuation: backslash-newline is replaced with a space // But don't add a space if the last value part already ends with whitespace let should_add_space = value_parts .last() .map(|s| !s.ends_with(' ') && !s.ends_with('\t')) .unwrap_or(true); if should_add_space { value_parts.push(" ".to_string()); } } SyntaxKind::WHITESPACE if found_equals && !value_parts.is_empty() => { // Only include whitespace that's part of the value (after we've started collecting) // Skip leading whitespace immediately after EQUALS value_parts.push(element.as_token()?.text().to_string()); } SyntaxKind::NEWLINE => break, _ => {} } } if value_parts.is_empty() { None } else { // Join all value parts (line continuations already converted to spaces) Some(value_parts.join("")) } } /// Get the raw value as it appears in the file (including line continuations) pub fn raw_value(&self) -> Option { let mut found_equals = false; let mut value_parts = Vec::new(); for element in self.0.children_with_tokens() { match element.kind() { SyntaxKind::EQUALS => found_equals = true, SyntaxKind::VALUE if found_equals => { value_parts.push(element.as_token()?.text().to_string()); } SyntaxKind::LINE_CONTINUATION if found_equals => { value_parts.push(element.as_token()?.text().to_string()); } SyntaxKind::WHITESPACE if found_equals => { value_parts.push(element.as_token()?.text().to_string()); } SyntaxKind::NEWLINE => break, _ => {} } } if value_parts.is_empty() { None } else { Some(value_parts.join("")) } } /// Get the value with escape sequences processed /// /// This processes C-style escape sequences as defined in the systemd specification: /// - `\n` - newline /// - `\t` - tab /// - `\r` - carriage return /// - `\\` - backslash /// - `\"` - double quote /// - `\'` - single quote /// - `\xhh` - hexadecimal byte (2 digits) /// - `\nnn` - octal byte (3 digits) /// - `\unnnn` - Unicode codepoint (4 hex digits) /// - `\Unnnnnnnn` - Unicode codepoint (8 hex digits) pub fn unescape_value(&self) -> Option { let value = self.value()?; Some(unescape_string(&value)) } /// Escape a string value for use in systemd unit files /// /// This escapes special characters that need escaping in systemd values: /// - backslash (`\`) becomes `\\` /// - newline (`\n`) becomes `\n` /// - tab (`\t`) becomes `\t` /// - carriage return (`\r`) becomes `\r` /// - double quote (`"`) becomes `\"` pub fn escape_value(value: &str) -> String { escape_string(value) } /// Check if the value is quoted (starts and ends with matching quotes) /// /// Returns the quote character if the value is quoted, None otherwise. /// Systemd supports both double quotes (`"`) and single quotes (`'`). pub fn is_quoted(&self) -> Option { let value = self.value()?; let trimmed = value.trim(); if trimmed.len() < 2 { return None; } let first = trimmed.chars().next()?; let last = trimmed.chars().last()?; if (first == '"' || first == '\'') && first == last { Some(first) } else { None } } /// Get the value with quotes removed (if present) /// /// According to systemd specification, quotes are removed when processing values. /// This method returns the value with outer quotes stripped if present. pub fn unquoted_value(&self) -> Option { let value = self.value()?; Some(unquote_string(&value)) } /// Get the value with quotes preserved as they appear in the file /// /// This is useful when you want to preserve the exact quoting style. pub fn quoted_value(&self) -> Option { // This is the same as value() - just provided for clarity self.value() } /// Parse the value as a space-separated list /// /// Many systemd directives use space-separated lists (e.g., `Wants=`, /// `After=`, `Before=`). This method splits the value on whitespace /// and returns a vector of strings. /// /// Empty values return an empty vector. pub fn value_as_list(&self) -> Vec { let value = match self.unquoted_value() { Some(v) => v, None => return Vec::new(), }; value.split_whitespace().map(|s| s.to_string()).collect() } /// Parse the value as a boolean /// /// According to systemd specification, boolean values accept: /// - Positive: `1`, `yes`, `true`, `on` /// - Negative: `0`, `no`, `false`, `off` /// /// Returns `None` if the value is not a valid boolean or if the entry has no value. /// /// # Example /// /// ``` /// # use systemd_unit_edit::SystemdUnit; /// # use std::str::FromStr; /// let unit = SystemdUnit::from_str("[Service]\nRemainAfterExit=yes\n").unwrap(); /// let section = unit.get_section("Service").unwrap(); /// let entry = section.entries().next().unwrap(); /// assert_eq!(entry.value_as_bool(), Some(true)); /// ``` pub fn value_as_bool(&self) -> Option { let value = self.unquoted_value()?; let value_lower = value.trim().to_lowercase(); match value_lower.as_str() { "1" | "yes" | "true" | "on" => Some(true), "0" | "no" | "false" | "off" => Some(false), _ => None, } } /// Format a boolean value for use in systemd unit files /// /// This converts a boolean to the canonical systemd format: /// - `true` becomes `"yes"` /// - `false` becomes `"no"` /// /// # Example /// /// ``` /// # use systemd_unit_edit::Entry; /// assert_eq!(Entry::format_bool(true), "yes"); /// assert_eq!(Entry::format_bool(false), "no"); /// ``` pub fn format_bool(value: bool) -> &'static str { if value { "yes" } else { "no" } } /// Expand systemd specifiers in the value /// /// This replaces systemd specifiers like `%i`, `%u`, `%h` with their /// values from the provided context. /// /// # Example /// /// ``` /// # use systemd_unit_edit::{SystemdUnit, SpecifierContext}; /// # use std::str::FromStr; /// let unit = SystemdUnit::from_str("[Service]\nWorkingDirectory=/var/lib/%i\n").unwrap(); /// let section = unit.get_section("Service").unwrap(); /// let entry = section.entries().next().unwrap(); /// /// let mut ctx = SpecifierContext::new(); /// ctx.set("i", "myinstance"); /// /// assert_eq!(entry.expand_specifiers(&ctx), Some("/var/lib/myinstance".to_string())); /// ``` pub fn expand_specifiers( &self, context: &crate::specifier::SpecifierContext, ) -> Option { let value = self.value()?; Some(context.expand(&value)) } /// Get the raw syntax node pub fn syntax(&self) -> &SyntaxNode { &self.0 } } impl AstNode for Entry { type Language = Lang; fn can_cast(kind: SyntaxKind) -> bool { kind == SyntaxKind::ENTRY } fn cast(node: SyntaxNode) -> Option { if node.kind() == SyntaxKind::ENTRY { Some(Entry(node)) } else { None } } fn syntax(&self) -> &SyntaxNode { &self.0 } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_simple() { let input = r#"[Unit] Description=Test Service After=network.target "#; let unit = SystemdUnit::from_str(input).unwrap(); assert_eq!(unit.sections().count(), 1); let section = unit.sections().next().unwrap(); assert_eq!(section.name(), Some("Unit".to_string())); assert_eq!(section.get("Description"), Some("Test Service".to_string())); assert_eq!(section.get("After"), Some("network.target".to_string())); } #[test] fn test_parse_with_comments() { let input = r#"# Top comment [Unit] # Comment before description Description=Test Service ; Semicolon comment After=network.target "#; let unit = SystemdUnit::from_str(input).unwrap(); assert_eq!(unit.sections().count(), 1); let section = unit.sections().next().unwrap(); assert_eq!(section.get("Description"), Some("Test Service".to_string())); } #[test] fn test_parse_multiple_sections() { let input = r#"[Unit] Description=Test Service [Service] Type=simple ExecStart=/usr/bin/test [Install] WantedBy=multi-user.target "#; let unit = SystemdUnit::from_str(input).unwrap(); assert_eq!(unit.sections().count(), 3); let unit_section = unit.get_section("Unit").unwrap(); assert_eq!( unit_section.get("Description"), Some("Test Service".to_string()) ); let service_section = unit.get_section("Service").unwrap(); assert_eq!(service_section.get("Type"), Some("simple".to_string())); assert_eq!( service_section.get("ExecStart"), Some("/usr/bin/test".to_string()) ); let install_section = unit.get_section("Install").unwrap(); assert_eq!( install_section.get("WantedBy"), Some("multi-user.target".to_string()) ); } #[test] fn test_parse_with_spaces() { let input = "[Unit]\nDescription = Test Service\n"; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); assert_eq!(section.get("Description"), Some("Test Service".to_string())); } #[test] fn test_line_continuation() { let input = "[Service]\nExecStart=/bin/echo \\\n hello world\n"; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); assert_eq!(entry.key(), Some("ExecStart".to_string())); // Line continuation: backslash is replaced with space assert_eq!(entry.value(), Some("/bin/echo hello world".to_string())); } #[test] fn test_lossless_roundtrip() { let input = r#"# Comment [Unit] Description=Test Service After=network.target [Service] Type=simple ExecStart=/usr/bin/test "#; let unit = SystemdUnit::from_str(input).unwrap(); let output = unit.text(); assert_eq!(input, output); } #[test] fn test_set_value() { let input = r#"[Unit] Description=Test Service "#; let unit = SystemdUnit::from_str(input).unwrap(); { let mut section = unit.sections().next().unwrap(); section.set("Description", "Updated Service"); } let section = unit.sections().next().unwrap(); assert_eq!( section.get("Description"), Some("Updated Service".to_string()) ); } #[test] fn test_add_new_entry() { let input = r#"[Unit] Description=Test Service "#; let unit = SystemdUnit::from_str(input).unwrap(); { let mut section = unit.sections().next().unwrap(); section.set("After", "network.target"); } let section = unit.sections().next().unwrap(); assert_eq!(section.get("Description"), Some("Test Service".to_string())); assert_eq!(section.get("After"), Some("network.target".to_string())); } #[test] fn test_multiple_values_same_key() { let input = r#"[Unit] Wants=foo.service Wants=bar.service "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); // get() returns the first value assert_eq!(section.get("Wants"), Some("foo.service".to_string())); // get_all() returns all values let all_wants = section.get_all("Wants"); assert_eq!(all_wants.len(), 2); assert_eq!(all_wants[0], "foo.service"); assert_eq!(all_wants[1], "bar.service"); } #[test] fn test_add_multiple_entries() { let input = r#"[Unit] Description=Test Service "#; let unit = SystemdUnit::from_str(input).unwrap(); { let mut section = unit.sections().next().unwrap(); section.add("Wants", "foo.service"); section.add("Wants", "bar.service"); } let section = unit.sections().next().unwrap(); let all_wants = section.get_all("Wants"); assert_eq!(all_wants.len(), 2); assert_eq!(all_wants[0], "foo.service"); assert_eq!(all_wants[1], "bar.service"); } #[test] fn test_remove_entry() { let input = r#"[Unit] Description=Test Service After=network.target "#; let unit = SystemdUnit::from_str(input).unwrap(); { let mut section = unit.sections().next().unwrap(); section.remove("After"); } let section = unit.sections().next().unwrap(); assert_eq!(section.get("Description"), Some("Test Service".to_string())); assert_eq!(section.get("After"), None); } #[test] fn test_remove_all_entries() { let input = r#"[Unit] Wants=foo.service Wants=bar.service Description=Test "#; let unit = SystemdUnit::from_str(input).unwrap(); { let mut section = unit.sections().next().unwrap(); section.remove_all("Wants"); } let section = unit.sections().next().unwrap(); assert_eq!(section.get_all("Wants").len(), 0); assert_eq!(section.get("Description"), Some("Test".to_string())); } #[test] fn test_unescape_basic() { let input = r#"[Unit] Description=Test\nService "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); assert_eq!(entry.value(), Some("Test\\nService".to_string())); assert_eq!(entry.unescape_value(), Some("Test\nService".to_string())); } #[test] fn test_unescape_all_escapes() { let input = r#"[Unit] Value=\n\t\r\\\"\'\x41\101\u0041\U00000041 "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); let unescaped = entry.unescape_value().unwrap(); // \n = newline, \t = tab, \r = carriage return, \\ = backslash // \" = quote, \' = single quote // \x41 = 'A', \101 = 'A', \u0041 = 'A', \U00000041 = 'A' assert_eq!(unescaped, "\n\t\r\\\"'AAAA"); } #[test] fn test_unescape_unicode() { let input = r#"[Unit] Value=Hello\u0020World\U0001F44D "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); let unescaped = entry.unescape_value().unwrap(); // \u0020 = space, \U0001F44D = 👍 assert_eq!(unescaped, "Hello World👍"); } #[test] fn test_escape_value() { let text = "Hello\nWorld\t\"Test\"\\Path"; let escaped = Entry::escape_value(text); assert_eq!(escaped, "Hello\\nWorld\\t\\\"Test\\\"\\\\Path"); } #[test] fn test_escape_unescape_roundtrip() { let original = "Test\nwith\ttabs\rand\"quotes\"\\backslash"; let escaped = Entry::escape_value(original); let unescaped = unescape_string(&escaped); assert_eq!(original, unescaped); } #[test] fn test_unescape_invalid_sequences() { // Invalid escape sequences should be kept as-is or handled gracefully let input = r#"[Unit] Value=\z\xFF\u12\U1234 "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); let unescaped = entry.unescape_value().unwrap(); // \z is unknown, \xFF has only 2 chars but needs hex, \u12 and \U1234 are incomplete assert!(unescaped.contains("\\z")); } #[test] fn test_quoted_double_quotes() { let input = r#"[Unit] Description="Test Service" "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); assert_eq!(entry.value(), Some("\"Test Service\"".to_string())); assert_eq!(entry.quoted_value(), Some("\"Test Service\"".to_string())); assert_eq!(entry.unquoted_value(), Some("Test Service".to_string())); assert_eq!(entry.is_quoted(), Some('"')); } #[test] fn test_quoted_single_quotes() { let input = r#"[Unit] Description='Test Service' "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); assert_eq!(entry.value(), Some("'Test Service'".to_string())); assert_eq!(entry.unquoted_value(), Some("Test Service".to_string())); assert_eq!(entry.is_quoted(), Some('\'')); } #[test] fn test_quoted_with_whitespace() { let input = r#"[Unit] Description=" Test Service " "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); // Quotes preserve internal whitespace assert_eq!(entry.unquoted_value(), Some(" Test Service ".to_string())); } #[test] fn test_unquoted_value() { let input = r#"[Unit] Description=Test Service "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); assert_eq!(entry.value(), Some("Test Service".to_string())); assert_eq!(entry.unquoted_value(), Some("Test Service".to_string())); assert_eq!(entry.is_quoted(), None); } #[test] fn test_mismatched_quotes() { let input = r#"[Unit] Description="Test Service' "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); // Mismatched quotes should not be considered quoted assert_eq!(entry.is_quoted(), None); assert_eq!(entry.unquoted_value(), Some("\"Test Service'".to_string())); } #[test] fn test_empty_quotes() { let input = r#"[Unit] Description="" "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); assert_eq!(entry.is_quoted(), Some('"')); assert_eq!(entry.unquoted_value(), Some("".to_string())); } #[test] fn test_value_as_list() { let input = r#"[Unit] After=network.target remote-fs.target "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); let list = entry.value_as_list(); assert_eq!(list.len(), 2); assert_eq!(list[0], "network.target"); assert_eq!(list[1], "remote-fs.target"); } #[test] fn test_value_as_list_single() { let input = r#"[Unit] After=network.target "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); let list = entry.value_as_list(); assert_eq!(list.len(), 1); assert_eq!(list[0], "network.target"); } #[test] fn test_value_as_list_empty() { let input = r#"[Unit] After= "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); let list = entry.value_as_list(); assert_eq!(list.len(), 0); } #[test] fn test_value_as_list_with_extra_whitespace() { let input = r#"[Unit] After= network.target remote-fs.target "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); let list = entry.value_as_list(); assert_eq!(list.len(), 2); assert_eq!(list[0], "network.target"); assert_eq!(list[1], "remote-fs.target"); } #[test] fn test_section_get_list() { let input = r#"[Unit] After=network.target remote-fs.target "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let list = section.get_list("After"); assert_eq!(list.len(), 2); assert_eq!(list[0], "network.target"); assert_eq!(list[1], "remote-fs.target"); } #[test] fn test_section_get_list_missing() { let input = r#"[Unit] Description=Test "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let list = section.get_list("After"); assert_eq!(list.len(), 0); } #[test] fn test_section_set_list() { let input = r#"[Unit] Description=Test "#; let unit = SystemdUnit::from_str(input).unwrap(); { let mut section = unit.sections().next().unwrap(); section.set_list("After", &["network.target", "remote-fs.target"]); } let section = unit.sections().next().unwrap(); let list = section.get_list("After"); assert_eq!(list.len(), 2); assert_eq!(list[0], "network.target"); assert_eq!(list[1], "remote-fs.target"); } #[test] fn test_section_set_list_replaces() { let input = r#"[Unit] After=foo.target "#; let unit = SystemdUnit::from_str(input).unwrap(); { let mut section = unit.sections().next().unwrap(); section.set_list("After", &["network.target", "remote-fs.target"]); } let section = unit.sections().next().unwrap(); let list = section.get_list("After"); assert_eq!(list.len(), 2); assert_eq!(list[0], "network.target"); assert_eq!(list[1], "remote-fs.target"); } #[test] fn test_value_as_bool_positive() { let inputs = vec!["yes", "true", "1", "on", "YES", "True", "ON"]; for input_val in inputs { let input = format!("[Service]\nRemainAfterExit={}\n", input_val); let unit = SystemdUnit::from_str(&input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); assert_eq!( entry.value_as_bool(), Some(true), "Failed for input: {}", input_val ); } } #[test] fn test_value_as_bool_negative() { let inputs = vec!["no", "false", "0", "off", "NO", "False", "OFF"]; for input_val in inputs { let input = format!("[Service]\nRemainAfterExit={}\n", input_val); let unit = SystemdUnit::from_str(&input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); assert_eq!( entry.value_as_bool(), Some(false), "Failed for input: {}", input_val ); } } #[test] fn test_value_as_bool_invalid() { let input = r#"[Service] RemainAfterExit=maybe "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); assert_eq!(entry.value_as_bool(), None); } #[test] fn test_value_as_bool_with_whitespace() { let input = r#"[Service] RemainAfterExit= yes "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); let entry = section.entries().next().unwrap(); assert_eq!(entry.value_as_bool(), Some(true)); } #[test] fn test_format_bool() { assert_eq!(Entry::format_bool(true), "yes"); assert_eq!(Entry::format_bool(false), "no"); } #[test] fn test_section_get_bool() { let input = r#"[Service] RemainAfterExit=yes Type=simple "#; let unit = SystemdUnit::from_str(input).unwrap(); let section = unit.sections().next().unwrap(); assert_eq!(section.get_bool("RemainAfterExit"), Some(true)); assert_eq!(section.get_bool("Type"), None); // Not a boolean assert_eq!(section.get_bool("Missing"), None); // Doesn't exist } #[test] fn test_section_set_bool() { let input = r#"[Service] Type=simple "#; let unit = SystemdUnit::from_str(input).unwrap(); { let mut section = unit.sections().next().unwrap(); section.set_bool("RemainAfterExit", true); section.set_bool("PrivateTmp", false); } let section = unit.sections().next().unwrap(); assert_eq!(section.get("RemainAfterExit"), Some("yes".to_string())); assert_eq!(section.get("PrivateTmp"), Some("no".to_string())); assert_eq!(section.get_bool("RemainAfterExit"), Some(true)); assert_eq!(section.get_bool("PrivateTmp"), Some(false)); } #[test] fn test_add_entry_with_trailing_whitespace() { // Section with trailing blank lines let input = r#"[Unit] Description=Test Service "#; let unit = SystemdUnit::from_str(input).unwrap(); { let mut section = unit.sections().next().unwrap(); section.add("After", "network.target"); } let output = unit.text(); // New entry should be added immediately after the last entry, not after whitespace let expected = r#"[Unit] Description=Test Service After=network.target "#; assert_eq!(output, expected); } #[test] fn test_set_new_entry_with_trailing_whitespace() { // Section with trailing blank lines let input = r#"[Unit] Description=Test Service "#; let unit = SystemdUnit::from_str(input).unwrap(); { let mut section = unit.sections().next().unwrap(); section.set("After", "network.target"); } let output = unit.text(); // New entry should be added immediately after the last entry, not after whitespace let expected = r#"[Unit] Description=Test Service After=network.target "#; assert_eq!(output, expected); } }