1use std::fs;
2use std::io::Write;
3use std::process::Command;
4use tempfile::NamedTempFile;
5
6macro_rules! debug {
7 ($($arg:tt)*) => {
8 if std::env::var("ECHO_COMMENT_DEBUG").is_ok() {
9 eprintln!($($arg)*);
10 }
11 };
12}
13
14#[derive(Debug)]
15pub enum Mode {
16 CommentToEcho, EchoToComment, }
19
20pub fn run_script(
21 script_path: &str,
22 script_args: &[String],
23 mode: Mode,
24) -> Result<(), Box<dyn std::error::Error>> {
25 debug!("DEBUG: Processing script: {}", script_path);
26 debug!("DEBUG: Mode: {:?}", mode);
27
28 let content = fs::read_to_string(script_path)
30 .map_err(|e| format!("Failed to read script '{}': {}", script_path, e))?;
31
32 debug!("DEBUG: Script content:\n{}", content);
33
34 let mut temp_file = NamedTempFile::new()?;
36
37 writeln!(temp_file, "#!/usr/bin/env bash")?;
39
40 let mut is_first_line = true;
42 for (i, line) in content.lines().enumerate() {
43 debug!("Processing line {}: '{}'", i, line);
44
45 if is_first_line && line.starts_with("#!") {
46 debug!("DEBUG: Skipping first-line shebang: {}", line);
48 is_first_line = false;
49 continue;
50 }
51
52 let processed_line = match mode {
53 Mode::CommentToEcho => process_comment_to_echo(line),
54 Mode::EchoToComment => process_echo_to_comment(line),
55 };
56
57 debug!("{} -> {}", line, processed_line);
58
59 if let Err(e) = writeln!(temp_file, "{}", processed_line) {
60 debug!("ERROR writing to temp file: {}", e);
61 return Err(e.into());
62 }
63 }
64
65 debug!("Finished processing all lines");
66
67 temp_file.flush()?;
69 let temp_path = temp_file.into_temp_path();
70
71 #[cfg(unix)]
73 {
74 use std::os::unix::fs::PermissionsExt;
75 let mut perms = fs::metadata(&temp_path)?.permissions();
76 perms.set_mode(0o755);
77 fs::set_permissions(&temp_path, perms)?;
78 }
79
80 let mut cmd = Command::new(&temp_path);
82 cmd.args(script_args);
83
84 let status = cmd
85 .status()
86 .map_err(|e| format!("Failed to execute processed script: {}", e))?;
87
88 temp_path.close()?;
90
91 std::process::exit(status.code().unwrap_or(1));
93}
94
95fn process_comment_to_echo(line: &str) -> String {
96 if let Some(comment) = extract_comment(line) {
97 match comment {
98 CommentType::Regular(content) => {
99 let indent = get_indent(line);
101 format!("{}echo \"{}\"", indent, escape_for_echo(&content))
102 }
103 CommentType::NoEcho(content) => {
104 let indent = get_indent(line);
106 if content.is_empty() {
107 format!("{}#", indent)
108 } else {
109 format!("{}# {}", indent, content)
110 }
111 }
112 CommentType::EscapedHash(content) => {
113 let indent = get_indent(line);
115 let echo_content = if content.is_empty() {
116 "#".to_string()
117 } else {
118 format!("# {}", content)
119 };
120 format!("{}echo \"{}\"", indent, escape_for_echo(&echo_content))
121 }
122 }
123 } else {
124 line.to_string()
126 }
127}
128
129fn process_echo_to_comment(line: &str) -> String {
130 if let Some(echo_content) = extract_echo(line) {
131 let indent = get_indent(line);
133
134 if let Some(content) = echo_content.strip_prefix("# ") {
136 if content.is_empty() {
138 format!("{}#\\#", indent)
139 } else {
140 format!("{}#\\# {}", indent, content)
141 }
142 } else if echo_content == "#" {
143 format!("{}#\\#", indent)
144 } else if echo_content.is_empty() {
145 format!("{}#", indent)
146 } else {
147 format!("{}# {}", indent, echo_content)
148 }
149 } else {
150 line.to_string()
152 }
153}
154
155#[derive(Debug, PartialEq)]
156enum CommentType {
157 Regular(String), NoEcho(String), EscapedHash(String), }
161
162fn extract_comment(line: &str) -> Option<CommentType> {
163 let trimmed = line.trim_start();
164
165 if let Some(rest) = trimmed.strip_prefix("#\\# ") {
167 return Some(CommentType::EscapedHash(rest.trim_start().to_string()));
168 } else if trimmed == "#\\#" {
169 return Some(CommentType::EscapedHash(String::new()));
170 }
171
172 if let Some(rest) = trimmed.strip_prefix("## ") {
174 return Some(CommentType::NoEcho(rest.trim_start().to_string()));
175 } else if trimmed == "##" {
176 return Some(CommentType::NoEcho(String::new()));
177 }
178
179 if let Some(rest) = trimmed.strip_prefix("# ") {
181 return Some(CommentType::Regular(rest.trim_start().to_string()));
182 } else if trimmed == "#" {
183 return Some(CommentType::Regular(String::new()));
184 }
185
186 None
187}
188
189fn extract_echo(line: &str) -> Option<String> {
190 let trimmed = line.trim_start();
191
192 if trimmed == "echo" {
194 return Some(String::new());
195 }
196
197 if let Some(rest) = trimmed.strip_prefix("echo ") {
199 let content = rest.trim();
200
201 if (content.starts_with('"') && content.ends_with('"'))
203 || (content.starts_with('\'') && content.ends_with('\''))
204 {
205 let inner = &content[1..content.len() - 1];
206 Some(unescape_from_echo(inner))
207 } else if content.is_empty() {
208 Some(String::new())
209 } else {
210 Some(content.to_string())
212 }
213 } else {
214 None
215 }
216}
217
218fn get_indent(line: &str) -> String {
219 line.chars().take_while(|c| c.is_whitespace()).collect()
220}
221
222fn escape_for_echo(text: &str) -> String {
223 text.replace('\\', "\\\\")
224 .replace('"', "\\\"")
225 .replace('$', "\\$")
226 .replace('`', "\\`")
227}
228
229fn unescape_from_echo(text: &str) -> String {
230 text.replace("\\\"", "\"")
231 .replace("\\$", "$")
232 .replace("\\`", "`")
233 .replace("\\\\", "\\")
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn test_extract_comment_types() {
242 assert_eq!(
244 extract_comment("# hello world"),
245 Some(CommentType::Regular("hello world".to_string()))
246 );
247 assert_eq!(
248 extract_comment(" # indented comment"),
249 Some(CommentType::Regular("indented comment".to_string()))
250 );
251 assert_eq!(
252 extract_comment("#"),
253 Some(CommentType::Regular(String::new()))
254 );
255
256 assert_eq!(
258 extract_comment("## private comment"),
259 Some(CommentType::NoEcho("private comment".to_string()))
260 );
261 assert_eq!(
262 extract_comment(" ## indented private"),
263 Some(CommentType::NoEcho("indented private".to_string()))
264 );
265 assert_eq!(
266 extract_comment("##"),
267 Some(CommentType::NoEcho(String::new()))
268 );
269
270 assert_eq!(
272 extract_comment("#\\# with hash"),
273 Some(CommentType::EscapedHash("with hash".to_string()))
274 );
275 assert_eq!(
276 extract_comment(" #\\# indented hash"),
277 Some(CommentType::EscapedHash("indented hash".to_string()))
278 );
279 assert_eq!(
280 extract_comment("#\\#"),
281 Some(CommentType::EscapedHash(String::new()))
282 );
283
284 assert_eq!(extract_comment("echo hello"), None);
286 assert_eq!(extract_comment("#!/bin/bash"), None);
287 assert_eq!(extract_comment("#no space"), None);
288 assert_eq!(extract_comment("not # a comment"), None);
289 }
290
291 #[test]
292 fn test_extract_echo() {
293 assert_eq!(
294 extract_echo("echo \"hello world\""),
295 Some("hello world".to_string())
296 );
297 assert_eq!(extract_echo(" echo 'test'"), Some("test".to_string()));
298 assert_eq!(extract_echo("echo"), Some(String::new()));
299 assert_eq!(extract_echo("# comment"), None);
300 assert_eq!(extract_echo("ls -la"), None);
301
302 assert_eq!(extract_echo("echo "), Some(String::new()));
304 assert_eq!(
305 extract_echo("echo unquoted text"),
306 Some("unquoted text".to_string())
307 );
308 assert_eq!(extract_echo("echo \"\""), Some(String::new()));
309 assert_eq!(extract_echo("echo ''"), Some(String::new()));
310 assert_eq!(
311 extract_echo("echo \"with \\\"escaped\\\" quotes\""),
312 Some("with \"escaped\" quotes".to_string())
313 );
314 assert_eq!(
315 extract_echo("echo \"$var and `cmd`\""),
316 Some("$var and `cmd`".to_string())
317 );
318 assert_eq!(
319 extract_echo(" echo \" spaced \" "),
320 Some(" spaced ".to_string())
321 );
322 assert_eq!(extract_echo("echoing"), None);
323 assert_eq!(extract_echo("echo-like"), None);
324 }
325
326 #[test]
327 fn test_get_indent() {
328 assert_eq!(get_indent("# comment"), "");
329 assert_eq!(get_indent(" # indented"), " ");
330 assert_eq!(get_indent("\t# tabbed"), "\t");
331 assert_eq!(get_indent(" \t mixed"), " \t ");
332 assert_eq!(get_indent("no indent"), "");
333 }
334
335 #[test]
336 fn test_escape_unescape_roundtrip() {
337 let test_cases = vec![
338 "simple text",
339 "text with \"quotes\"",
340 "text with $variables",
341 "text with `commands`",
342 "text with \\backslashes",
343 "complex: \"$var\" and `echo test` with \\path",
344 "",
345 ];
346
347 for original in test_cases {
348 let escaped = escape_for_echo(original);
349 let unescaped = unescape_from_echo(&escaped);
350 assert_eq!(original, unescaped, "Failed roundtrip for: {}", original);
351 }
352 }
353
354 #[test]
355 fn test_process_comment_to_echo() {
356 assert_eq!(process_comment_to_echo("# test"), "echo \"test\"");
358 assert_eq!(
359 process_comment_to_echo(" # indented"),
360 " echo \"indented\""
361 );
362 assert_eq!(process_comment_to_echo("#"), "echo \"\"");
363
364 assert_eq!(process_comment_to_echo("## private"), "# private");
366 assert_eq!(
367 process_comment_to_echo(" ## indented private"),
368 " # indented private"
369 );
370 assert_eq!(process_comment_to_echo("##"), "#");
371
372 assert_eq!(
374 process_comment_to_echo("#\\# with hash"),
375 "echo \"# with hash\""
376 );
377 assert_eq!(
378 process_comment_to_echo(" #\\# indented hash"),
379 " echo \"# indented hash\""
380 );
381 assert_eq!(process_comment_to_echo("#\\#"), "echo \"#\"");
382
383 assert_eq!(process_comment_to_echo("not a comment"), "not a comment");
385 assert_eq!(process_comment_to_echo("echo already"), "echo already");
386 }
387
388 #[test]
389 fn test_process_echo_to_comment() {
390 assert_eq!(process_echo_to_comment("echo \"test\""), "# test");
392 assert_eq!(process_echo_to_comment(" echo 'indented'"), " # indented");
393 assert_eq!(process_echo_to_comment("echo"), "#");
394
395 assert_eq!(
397 process_echo_to_comment("echo \"# with hash\""),
398 "#\\# with hash"
399 );
400 assert_eq!(
401 process_echo_to_comment(" echo '# indented hash'"),
402 " #\\# indented hash"
403 );
404 assert_eq!(process_echo_to_comment("echo \"#\""), "#\\#");
405
406 assert_eq!(process_echo_to_comment("not an echo"), "not an echo");
408 assert_eq!(
409 process_echo_to_comment("# already comment"),
410 "# already comment"
411 );
412 }
413
414 #[test]
415 fn test_bidirectional_conversion() {
416 let comment_line = " # Hello world";
418 let echo_line = process_comment_to_echo(comment_line);
419 assert_eq!(echo_line, " echo \"Hello world\"");
420 let back_to_comment = process_echo_to_comment(&echo_line);
421 assert_eq!(back_to_comment, " # Hello world");
422
423 let no_echo_comment = " ## Private note";
425 let processed = process_comment_to_echo(no_echo_comment);
426 assert_eq!(processed, " # Private note");
427
428 let escaped_comment = " #\\# Show hash";
430 let echo_with_hash = process_comment_to_echo(escaped_comment);
431 assert_eq!(echo_with_hash, " echo \"# Show hash\"");
432 let back_to_escaped = process_echo_to_comment(&echo_with_hash);
433 assert_eq!(back_to_escaped, " #\\# Show hash");
434 }
435
436 #[test]
437 fn test_special_characters_in_comments() {
438 let special_comment = "# File: $HOME/test & echo \"hello\"";
440 let echo_line = process_comment_to_echo(special_comment);
441 assert_eq!(
442 echo_line,
443 "echo \"File: \\$HOME/test & echo \\\"hello\\\"\""
444 );
445
446 let back_to_comment = process_echo_to_comment(&echo_line);
447 assert_eq!(back_to_comment, "# File: $HOME/test & echo \"hello\"");
448 }
449
450 #[test]
451 fn test_empty_and_whitespace() {
452 assert_eq!(process_comment_to_echo(""), "");
453 assert_eq!(process_comment_to_echo(" "), " ");
454 assert_eq!(process_echo_to_comment(""), "");
455 assert_eq!(process_echo_to_comment(" "), " ");
456 }
457
458 #[test]
459 fn test_edge_cases_with_escapes() {
460 assert_eq!(
462 extract_comment("# extra spaces "),
463 Some(CommentType::Regular("extra spaces ".to_string()))
464 );
465 assert_eq!(
466 extract_comment("## extra spaces "),
467 Some(CommentType::NoEcho("extra spaces ".to_string()))
468 );
469 assert_eq!(
470 extract_comment("#\\# extra spaces "),
471 Some(CommentType::EscapedHash("extra spaces ".to_string()))
472 );
473
474 assert_eq!(
476 extract_comment("\t# tab indented"),
477 Some(CommentType::Regular("tab indented".to_string()))
478 );
479 assert_eq!(
480 extract_comment("\t## tab private"),
481 Some(CommentType::NoEcho("tab private".to_string()))
482 );
483 assert_eq!(
484 extract_comment("\t#\\# tab hash"),
485 Some(CommentType::EscapedHash("tab hash".to_string()))
486 );
487 }
488}