1use std::{io::Write as _, mem};
2
3use convert_case::{Case, Casing as _};
4
5use super::*;
6
7const RP_HELP: &str = r#"
8Replace ${VAR|<file|!cmd} occurrences in the content
9
10The request file:
11 source/greetings.txt.zr-rp
12 | Thanks for using ${ZR_APP}!
13
14Writes the text content with ZR_APP replaced:
15 target/greetings.txt
16 | Thanks for using Foo App!
17
18The parameters syntax is ${VAR|!|<[:[case]][?else]}:
19
20${VAR} — Replaces with the env var value, or fails if it is not set.
21${VAR:case} — Replaces with the env var value, case converted.
22${VAR:?else} — If VAR is not set or is empty uses 'else' instead.
23
24${<file.txt} — Replaces with the 'file.txt' content.
25 Paths are relative to the workspace root.
26${<file:case} — Replaces with the 'file.txt' content, case converted.
27${<file:?else} — If file cannot be read or is empty uses 'else' instead.
28
29${!cmd -h} — Replaces with the stdout of the bash script line.
30 The script runs the same bash used by '.zr-sh'.
31 The script must be defined all in one line.
32 A separate bash instance is used for each occurrence.
33 The working directory is the workspace root.
34${!cmd:case} — Replaces with the stdout, case converted.
35 If the script contains ':' quote it with double quotes\"
36${!cmd:?else} — If script fails or ha no stdout, uses 'else' instead.
37
38$${VAR} — Escapes $, replaces with '${VAR}'.
39
40The :case functions are:
41
42:k or :kebab — kebab-case (cleaned)
43:K or :KEBAB — UPPER-KEBAB-CASE (cleaned)
44:s or :snake — snake_case (cleaned)
45:S or :SNAKE — UPPER_SNAKE_CASE (cleaned)
46:l or :lower — lower case
47:U or :UPPER — UPPER CASE
48:T or :Title — Title Case
49:c or :camel — camelCase (cleaned)
50:P or :Pascal — PascalCase (cleaned)
51:Tr or :Train — Train-Case (cleaned)
52: — Unchanged
53:clean — Cleaned
54:f or :file — Sanitize file name
55
56Cleaned values only keep ascii alphabetic first char and ascii alphanumerics, ' ', '-' and '_' other chars.
57More then one case function can be used, separated by pipe ':T|f' converts to title case and sanitize for file name.
58
59
60The fallback(:?else) can have nested ${...} patterns.
61You can set both case and else: '${VAR:case?else}'.
62
63Variables:
64
65All env variables can be used, of particular use with this tool are:
66
67ZR_APP_ID — package.metadata.zng.about.app_id or "qualifier.org.app" in snake_case
68ZR_APP — package.metadata.zng.about.app or package.name
69ZR_ORG — package.metadata.zng.about.org or the first package.authors
70ZR_VERSION — package.version
71ZR_DESCRIPTION — package.description
72ZR_HOMEPAGE — package.homepage
73ZR_LICENSE — package.license
74ZR_PKG_NAME — package.name
75ZR_PKG_AUTHORS — package.authors
76ZR_CRATE_NAME — package.name in snake_case
77ZR_QUALIFIER — package.metadata.zng.about.qualifier or the first components `ZR_APP_ID` except the last two
78ZR_META_*` — any other custom string value in package.metadata.zng.about.*
79
80See `zng::env::about` for more details about metadata vars.
81See the cargo-zng crate docs for a full list of ZR vars.
82
83"#;
84pub(super) fn rp() {
85 help(RP_HELP);
86
87 let content = fs::File::open(path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("cannot read, {e}"));
89 let target = path(ZR_TARGET);
90 let target = fs::File::create(target).unwrap_or_else(|e| fatal!("cannot write, {e}"));
91 let mut target = io::BufWriter::new(target);
92
93 let mut content = io::BufReader::new(content);
94 let mut line = String::new();
95 let mut ln = 1;
96 while content.read_line(&mut line).unwrap_or_else(|e| fatal!("cannot read, {e}")) > 0 {
97 let line_r = replace(&line, 0).unwrap_or_else(|e| fatal!("line {ln}, {e}"));
98 target.write_all(line_r.as_bytes()).unwrap_or_else(|e| fatal!("cannot write, {e}"));
99 ln += 1;
100 line.clear();
101 }
102 target.flush().unwrap_or_else(|e| fatal!("cannot write, {e}"));
103}
104
105const MAX_RECURSION: usize = 32;
106fn replace(line: &str, recursion_depth: usize) -> Result<String, String> {
107 let mut n2 = '\0';
108 let mut n1 = '\0';
109 let mut out = String::with_capacity(line.len());
110
111 let mut iterator = line.char_indices();
112 'main: while let Some((ci, c)) = iterator.next() {
113 if n1 == '$' && c == '{' {
114 out.pop();
115 if n2 == '$' {
116 out.push('{');
117 n1 = '{';
118 continue 'main;
119 }
120
121 let start = ci + 1;
122 let mut depth = 0;
123 let mut end = usize::MAX;
124 'seek_end: for (i, c) in iterator.by_ref() {
125 if c == '{' {
126 depth += 1;
127 } else if c == '}' {
128 if depth == 0 {
129 end = i;
130 break 'seek_end;
131 }
132 depth -= 1;
133 }
134 }
135 if end == usize::MAX {
136 let end = (start + 10).min(line.len());
137 return Err(format!("replace not closed at: ${{{}", &line[start..end]));
138 } else {
139 let mut var = &line[start..end];
140 let mut case = "";
141 let mut fallback = None;
142
143 let mut search_start = 0;
145 if var.starts_with('!') {
146 let mut quoted = false;
147 let mut escape_next = false;
148 for (i, c) in var.char_indices() {
149 if mem::take(&mut escape_next) {
150 continue;
151 }
152 if c == '\\' {
153 escape_next = true;
154 } else if c == '"' {
155 quoted = !quoted;
156 } else if !quoted && c == ':' {
157 search_start = i;
158 break;
159 }
160 }
161 }
162 if let Some(i) = var[search_start..].find(':') {
163 let i = search_start + i;
164 case = &var[i + 1..];
165 var = &var[..i];
166 if let Some(i) = case.find('?') {
167 fallback = Some(&case[i + 1..]);
168 case = &case[..i];
169 }
170 }
171
172 let value = if let Some(path) = var.strip_prefix('<') {
173 match std::fs::read_to_string(path) {
174 Ok(s) => Some(s),
175 Err(e) => {
176 error!("cannot read `{path}`, {e}");
177 None
178 }
179 }
180 } else if let Some(script) = var.strip_prefix('!') {
181 match sh_run(script.to_owned(), true, None) {
182 Ok(r) => Some(r),
183 Err(e) => fatal!("{e}"),
184 }
185 } else {
186 env::var(var).ok()
187 };
188
189 let value = match value {
190 Some(s) => {
191 let st = s.trim();
192 if st.is_empty() {
193 None
194 } else if st == s {
195 Some(s)
196 } else {
197 Some(st.to_owned())
198 }
199 }
200 _ => None,
201 };
202
203 if let Some(mut value) = value {
204 for case in case.split('|') {
205 value = match case {
206 "k" | "kebab" => util::clean_value(&value, false).unwrap().to_case(Case::Kebab),
207 "K" | "KEBAB" => util::clean_value(&value, false).unwrap().to_case(Case::UpperKebab),
208 "s" | "snake" => util::clean_value(&value, false).unwrap().to_case(Case::Snake),
209 "S" | "SNAKE" => util::clean_value(&value, false).unwrap().to_case(Case::UpperSnake),
210 "l" | "lower" => value.to_case(Case::Lower),
211 "U" | "UPPER" => value.to_case(Case::Upper),
212 "T" | "Title" => value.to_case(Case::Title),
213 "c" | "camel" => util::clean_value(&value, false).unwrap().to_case(Case::Camel),
214 "P" | "Pascal" => util::clean_value(&value, false).unwrap().to_case(Case::Pascal),
215 "Tr" | "Train" => util::clean_value(&value, false).unwrap().to_case(Case::Train),
216 "" => value,
217 "clean" => util::clean_value(&value, false).unwrap(),
218 "f" | "file" => sanitise_file_name::sanitise(&value),
219 unknown => return Err(format!("unknown case '{unknown}'")),
220 };
221 }
222 out.push_str(&value);
223 } else if let Some(fallback) = fallback {
224 if let Some(error) = fallback.strip_prefix('!') {
225 if error.contains('$') && recursion_depth < MAX_RECURSION {
226 return Err(replace(error, recursion_depth + 1).unwrap_or_else(|_| error.to_owned()));
227 } else {
228 return Err(error.to_owned());
229 }
230 } else if fallback.contains('$') && recursion_depth < MAX_RECURSION {
231 out.push_str(&replace(fallback, recursion_depth + 1)?);
232 } else {
233 out.push_str(fallback);
234 }
235 } else {
236 return Err(format!("${{{var}}} output is empty"));
237 }
238 }
239 } else {
240 out.push(c);
241 }
242 n2 = n1;
243 n1 = c;
244 }
245 Ok(out)
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn replace_tests() {
254 unsafe {
255 std::env::set_var("ZR_RP_TEST", "test value");
259 }
260
261 assert_eq!("", replace("", 0).unwrap());
262 assert_eq!("normal text", replace("normal text", 0).unwrap());
263 assert_eq!("escaped ${NOT}", replace("escaped $${NOT}", 0).unwrap());
264 assert_eq!("replace 'test value'", replace("replace '${ZR_RP_TEST}'", 0).unwrap());
265 assert_eq!("${} output is empty", replace("empty '${}'", 0).unwrap_err()); assert_eq!(
267 "${ZR_RP_TEST_NOT_SET} output is empty",
268 replace("not set '${ZR_RP_TEST_NOT_SET}'", 0).unwrap_err()
269 );
270 assert_eq!(
271 "not set 'fallback!'",
272 replace("not set '${ZR_RP_TEST_NOT_SET:?fallback!}'", 0).unwrap()
273 );
274 assert_eq!(
275 "not set 'nested 'test value'.'",
276 replace("not set '${ZR_RP_TEST_NOT_SET:?nested '${ZR_RP_TEST}'.}'", 0).unwrap()
277 );
278 assert_eq!("test value", replace("${ZR_RP_TEST_NOT_SET:?${ZR_RP_TEST}}", 0).unwrap());
279 assert_eq!(
280 "curly test value",
281 replace("curly ${ZR_RP_TEST:?{not {what} {is} {going {on {here {:?}}}}}}", 0).unwrap()
282 );
283
284 assert_eq!("replace not closed at: ${MISSING", replace("${MISSING", 0).unwrap_err());
285 assert_eq!("replace not closed at: ${MIS", replace("${MIS", 0).unwrap_err());
286 assert_eq!("replace not closed at: ${MIS:?{", replace("${MIS:?{", 0).unwrap_err());
287 assert_eq!("replace not closed at: ${MIS:?{}", replace("${MIS:?{}", 0).unwrap_err());
288
289 assert_eq!("TEST VALUE", replace("${ZR_RP_TEST:U}", 0).unwrap());
290 assert_eq!("TEST-VALUE", replace("${ZR_RP_TEST:K}", 0).unwrap());
291 assert_eq!("TEST_VALUE", replace("${ZR_RP_TEST:S}", 0).unwrap());
292 assert_eq!("testValue", replace("${ZR_RP_TEST:c}", 0).unwrap());
293 }
294
295 #[test]
296 fn replace_cmd_case() {
297 assert_eq!("cmd HELLO:?WORLD", replace("cmd ${!printf \"hello:?world\":U}", 0).unwrap(),)
298 }
299}