systemd_unit_edit/
specifier.rs

1//! Systemd specifier expansion support
2//!
3//! Systemd uses percent-escaped specifiers in unit files that are expanded
4//! at runtime. This module provides functionality to expand these specifiers.
5
6use std::collections::HashMap;
7
8/// Context for expanding systemd specifiers
9///
10/// This contains the values that will be substituted when expanding
11/// specifiers in unit file values.
12#[derive(Debug, Clone, Default)]
13pub struct SpecifierContext {
14    values: HashMap<String, String>,
15}
16
17impl SpecifierContext {
18    /// Create a new empty specifier context
19    pub fn new() -> Self {
20        Self {
21            values: HashMap::new(),
22        }
23    }
24
25    /// Set a specifier value
26    ///
27    /// # Example
28    ///
29    /// ```
30    /// # use systemd_unit_edit::SpecifierContext;
31    /// let mut ctx = SpecifierContext::new();
32    /// ctx.set("i", "instance");
33    /// ctx.set("u", "user");
34    /// ```
35    pub fn set(&mut self, specifier: &str, value: &str) {
36        self.values.insert(specifier.to_string(), value.to_string());
37    }
38
39    /// Get a specifier value
40    pub fn get(&self, specifier: &str) -> Option<&str> {
41        self.values.get(specifier).map(|s| s.as_str())
42    }
43
44    /// Create a context with common system specifiers
45    ///
46    /// This sets up commonly used specifiers with their values:
47    /// - `%n`: Unit name (without type suffix)
48    /// - `%N`: Full unit name
49    /// - `%p`: Prefix (for template units)
50    /// - `%i`: Instance (for template units)
51    ///
52    /// # Example
53    ///
54    /// ```
55    /// # use systemd_unit_edit::SpecifierContext;
56    /// let ctx = SpecifierContext::with_unit_name("foo@bar.service");
57    /// assert_eq!(ctx.get("N"), Some("foo@bar.service"));
58    /// assert_eq!(ctx.get("n"), Some("foo@bar"));
59    /// assert_eq!(ctx.get("p"), Some("foo"));
60    /// assert_eq!(ctx.get("i"), Some("bar"));
61    /// ```
62    pub fn with_unit_name(unit_name: &str) -> Self {
63        let mut ctx = Self::new();
64
65        // Full unit name
66        ctx.set("N", unit_name);
67
68        // Unit name without suffix
69        let name_without_suffix = unit_name
70            .rsplit_once('.')
71            .map(|(name, _)| name)
72            .unwrap_or(unit_name);
73        ctx.set("n", name_without_suffix);
74
75        // For template units (foo@instance.service)
76        if let Some((prefix, instance_with_suffix)) = name_without_suffix.split_once('@') {
77            ctx.set("p", prefix);
78            ctx.set("i", instance_with_suffix);
79        }
80
81        ctx
82    }
83
84    /// Expand specifiers in a string
85    ///
86    /// This replaces all `%X` patterns with their corresponding values from the context.
87    /// `%%` is replaced with a single `%`.
88    ///
89    /// # Example
90    ///
91    /// ```
92    /// # use systemd_unit_edit::SpecifierContext;
93    /// let mut ctx = SpecifierContext::new();
94    /// ctx.set("i", "myinstance");
95    /// ctx.set("u", "myuser");
96    ///
97    /// let result = ctx.expand("/var/lib/%i/data/%u");
98    /// assert_eq!(result, "/var/lib/myinstance/data/myuser");
99    /// ```
100    pub fn expand(&self, input: &str) -> String {
101        let mut result = String::new();
102        let mut chars = input.chars().peekable();
103
104        while let Some(ch) = chars.next() {
105            if ch == '%' {
106                if let Some(&next) = chars.peek() {
107                    chars.next(); // consume the peeked character
108                    if next == '%' {
109                        // %% -> %
110                        result.push('%');
111                    } else {
112                        // %X -> lookup
113                        let specifier = next.to_string();
114                        if let Some(value) = self.get(&specifier) {
115                            result.push_str(value);
116                        } else {
117                            // Unknown specifier, keep as-is
118                            result.push('%');
119                            result.push(next);
120                        }
121                    }
122                } else {
123                    // % at end of string
124                    result.push('%');
125                }
126            } else {
127                result.push(ch);
128            }
129        }
130
131        result
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_basic_expansion() {
141        let mut ctx = SpecifierContext::new();
142        ctx.set("i", "instance");
143        ctx.set("u", "user");
144
145        assert_eq!(ctx.expand("Hello %i"), "Hello instance");
146        assert_eq!(ctx.expand("%u@%i"), "user@instance");
147        assert_eq!(ctx.expand("/home/%u/%i"), "/home/user/instance");
148    }
149
150    #[test]
151    fn test_percent_escape() {
152        let ctx = SpecifierContext::new();
153        assert_eq!(ctx.expand("100%% complete"), "100% complete");
154        assert_eq!(ctx.expand("%%u"), "%u");
155    }
156
157    #[test]
158    fn test_unknown_specifier() {
159        let ctx = SpecifierContext::new();
160        // Unknown specifiers are kept as-is
161        assert_eq!(ctx.expand("%x"), "%x");
162        assert_eq!(ctx.expand("test %z end"), "test %z end");
163    }
164
165    #[test]
166    fn test_percent_at_end() {
167        let ctx = SpecifierContext::new();
168        assert_eq!(ctx.expand("test%"), "test%");
169    }
170
171    #[test]
172    fn test_with_unit_name_simple() {
173        let ctx = SpecifierContext::with_unit_name("foo.service");
174        assert_eq!(ctx.get("N"), Some("foo.service"));
175        assert_eq!(ctx.get("n"), Some("foo"));
176        assert_eq!(ctx.get("p"), None);
177        assert_eq!(ctx.get("i"), None);
178    }
179
180    #[test]
181    fn test_with_unit_name_template() {
182        let ctx = SpecifierContext::with_unit_name("foo@bar.service");
183        assert_eq!(ctx.get("N"), Some("foo@bar.service"));
184        assert_eq!(ctx.get("n"), Some("foo@bar"));
185        assert_eq!(ctx.get("p"), Some("foo"));
186        assert_eq!(ctx.get("i"), Some("bar"));
187
188        assert_eq!(ctx.expand("Unit %N"), "Unit foo@bar.service");
189        assert_eq!(ctx.expand("Prefix %p"), "Prefix foo");
190        assert_eq!(ctx.expand("Instance %i"), "Instance bar");
191    }
192
193    #[test]
194    fn test_with_unit_name_complex_instance() {
195        let ctx = SpecifierContext::with_unit_name("getty@tty1.service");
196        assert_eq!(ctx.get("p"), Some("getty"));
197        assert_eq!(ctx.get("i"), Some("tty1"));
198        assert_eq!(ctx.expand("/dev/%i"), "/dev/tty1");
199    }
200
201    #[test]
202    fn test_multiple_specifiers() {
203        let mut ctx = SpecifierContext::new();
204        ctx.set("i", "inst");
205        ctx.set("u", "usr");
206        ctx.set("h", "/home/usr");
207
208        assert_eq!(
209            ctx.expand("%h/.config/%i/data"),
210            "/home/usr/.config/inst/data"
211        );
212    }
213
214    #[test]
215    fn test_no_specifiers() {
216        let ctx = SpecifierContext::new();
217        assert_eq!(ctx.expand("plain text"), "plain text");
218        assert_eq!(ctx.expand("/etc/config"), "/etc/config");
219    }
220}