Skip to main content

lore/
params.rs

1//! Placeholder syntax for command definitions.
2//!
3//! A placeholder is written `<name>` or `<name:default>`. Names may contain
4//! ASCII letters, digits, `_` and `-`. A literal angle bracket is escaped with a
5//! preceding backslash.
6//!
7//! Angle brackets were chosen over `{{name}}` because Go template syntax occurs
8//! constantly in the docker and kubectl commands this tool targets.
9
10use std::collections::BTreeMap;
11use std::ops::Range;
12
13const ESCAPE: u8 = b'\\';
14const OPEN: u8 = b'<';
15const CLOSE: u8 = b'>';
16
17/// One placeholder occurrence in a command string.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Placeholder {
20    pub name: String,
21    pub default: Option<String>,
22    /// Byte range the placeholder occupies, including the angle brackets.
23    pub span: Range<usize>,
24}
25
26/// Every placeholder in `cmd`, in the order it appears.
27pub fn parse(cmd: &str) -> Vec<Placeholder> {
28    let bytes = cmd.as_bytes();
29    let mut found = Vec::new();
30    let mut i = 0;
31
32    while i < bytes.len() {
33        match bytes[i] {
34            ESCAPE if bytes.get(i + 1) == Some(&OPEN) => i += 2,
35            OPEN => match scan(cmd, i) {
36                Some(placeholder) => {
37                    i = placeholder.span.end;
38                    found.push(placeholder);
39                }
40                None => i += 1,
41            },
42            _ => i += 1,
43        }
44    }
45
46    found
47}
48
49/// Distinct placeholder names in `cmd`, in the order they first appear.
50pub fn names(cmd: &str) -> Vec<String> {
51    let mut seen = Vec::new();
52    for placeholder in parse(cmd) {
53        if !seen.contains(&placeholder.name) {
54            seen.push(placeholder.name);
55        }
56    }
57    seen
58}
59
60/// Substitutes placeholder values and resolves escaped angle brackets.
61///
62/// A placeholder missing from `values` falls back to its inline default, then to
63/// an empty string.
64pub fn render(cmd: &str, values: &BTreeMap<String, String>) -> String {
65    let bytes = cmd.as_bytes();
66    let mut out = String::with_capacity(cmd.len());
67    let mut i = 0;
68
69    while i < bytes.len() {
70        match bytes[i] {
71            ESCAPE if bytes.get(i + 1) == Some(&OPEN) => {
72                out.push(OPEN as char);
73                i += 2;
74            }
75            OPEN => match scan(cmd, i) {
76                Some(placeholder) => {
77                    let value = values
78                        .get(&placeholder.name)
79                        .or(placeholder.default.as_ref())
80                        .map(String::as_str)
81                        .unwrap_or_default();
82                    out.push_str(value);
83                    i = placeholder.span.end;
84                }
85                None => {
86                    out.push(OPEN as char);
87                    i += 1;
88                }
89            },
90            ESCAPE => {
91                out.push(ESCAPE as char);
92                i += 1;
93            }
94            _ => {
95                let start = i;
96                i += 1;
97                while i < bytes.len() && !matches!(bytes[i], OPEN | ESCAPE) {
98                    i += 1;
99                }
100                out.push_str(&cmd[start..i]);
101            }
102        }
103    }
104
105    out
106}
107
108/// Reads one placeholder starting at the opening bracket at `start`.
109fn scan(cmd: &str, start: usize) -> Option<Placeholder> {
110    let bytes = cmd.as_bytes();
111    let name_start = start + 1;
112    let mut i = name_start;
113
114    while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || matches!(bytes[i], b'_' | b'-')) {
115        i += 1;
116    }
117    if i == name_start {
118        return None;
119    }
120    let name = cmd[name_start..i].to_string();
121
122    let default = if bytes.get(i) == Some(&b':') {
123        let value_start = i + 1;
124        while i < bytes.len() && bytes[i] != CLOSE {
125            i += 1;
126        }
127        Some(cmd[value_start..i].to_string())
128    } else {
129        None
130    };
131
132    if bytes.get(i) != Some(&CLOSE) {
133        return None;
134    }
135
136    Some(Placeholder {
137        name,
138        default,
139        span: start..i + 1,
140    })
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn parsed(cmd: &str) -> Vec<(String, Option<String>)> {
148        parse(cmd)
149            .into_iter()
150            .map(|p| (p.name, p.default))
151            .collect()
152    }
153
154    #[test]
155    fn reads_a_bare_placeholder() {
156        assert_eq!(
157            parsed("docker logs <container>"),
158            [("container".to_string(), None)]
159        );
160    }
161
162    #[test]
163    fn reads_an_inline_default() {
164        assert_eq!(
165            parsed("kubectl get pods -n <namespace:default>"),
166            [("namespace".to_string(), Some("default".to_string()))]
167        );
168    }
169
170    #[test]
171    fn reads_hyphenated_names_in_order() {
172        assert_eq!(
173            parsed("kubectl port-forward svc/<service> <local-port>:<remote-port>"),
174            [
175                ("service".to_string(), None),
176                ("local-port".to_string(), None),
177                ("remote-port".to_string(), None),
178            ]
179        );
180    }
181
182    #[test]
183    fn ignores_go_template_syntax() {
184        let cmd = r#"docker ps --format "table {{.Names}}\t{{.Status}}""#;
185        assert!(parse(cmd).is_empty());
186    }
187
188    #[test]
189    fn ignores_shell_redirection() {
190        assert!(parse("mysql -u root < dump.sql").is_empty());
191        assert!(parse("make 2>&1 | tee log").is_empty());
192        assert!(parse("cat <file.txt").is_empty());
193    }
194
195    #[test]
196    fn escaped_bracket_is_not_a_placeholder() {
197        let cmd = r"echo \<literal>";
198        assert!(parse(cmd).is_empty());
199        assert_eq!(render(cmd, &BTreeMap::new()), "echo <literal>");
200    }
201
202    #[test]
203    fn render_substitutes_values_over_defaults() {
204        let values = BTreeMap::from([("namespace".to_string(), "prod".to_string())]);
205        assert_eq!(
206            render("kubectl get pods -n <namespace:default>", &values),
207            "kubectl get pods -n prod"
208        );
209    }
210
211    #[test]
212    fn render_falls_back_to_the_default() {
213        assert_eq!(
214            render("kubectl get pods -n <namespace:default>", &BTreeMap::new()),
215            "kubectl get pods -n default"
216        );
217    }
218
219    #[test]
220    fn render_leaves_go_templates_untouched() {
221        let cmd = r"docker inspect -f '{{.Id}}' <container>";
222        let values = BTreeMap::from([("container".to_string(), "web".to_string())]);
223        assert_eq!(render(cmd, &values), r"docker inspect -f '{{.Id}}' web");
224    }
225
226    #[test]
227    fn render_preserves_multibyte_text() {
228        let values = BTreeMap::from([("dir".to_string(), "günlük".to_string())]);
229        assert_eq!(
230            render("echo 'ölçüm →' && ls <dir>", &values),
231            "echo 'ölçüm →' && ls günlük"
232        );
233    }
234
235    #[test]
236    fn parse_handles_multibyte_text_before_a_placeholder() {
237        assert_eq!(parsed("echo 'ölçüm' <path>"), [("path".to_string(), None)]);
238    }
239
240    #[test]
241    fn lone_backslash_is_preserved() {
242        assert_eq!(
243            render(r"copy C:\src\file <dst>", &BTreeMap::new()),
244            r"copy C:\src\file "
245        );
246    }
247
248    #[test]
249    fn names_are_deduplicated_in_first_appearance_order() {
250        assert_eq!(
251            names("cp <src> <dst> && diff <src> <dst>"),
252            ["src".to_string(), "dst".to_string()]
253        );
254    }
255}