1use std::collections::BTreeMap;
11use std::ops::Range;
12
13const ESCAPE: u8 = b'\\';
14const OPEN: u8 = b'<';
15const CLOSE: u8 = b'>';
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Placeholder {
20 pub name: String,
21 pub default: Option<String>,
22 pub span: Range<usize>,
24}
25
26pub 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
49pub 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
60pub 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
108fn 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}