echo_comment/
lib.rs

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, // echo-comment: comments become echo statements
17    EchoToComment, // comment-echo: echo statements become comments
18}
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    // Read the input script
29    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    // Create a temporary file for the processed script
35    let mut temp_file = NamedTempFile::new()?;
36
37    // Always start with a proper shebang
38    writeln!(temp_file, "#!/usr/bin/env bash")?;
39
40    // Process each line based on mode
41    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            // Skip original shebang - we already added our own
47            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    // Flush and persist the temp file to get a path we can execute
68    temp_file.flush()?;
69    let temp_path = temp_file.into_temp_path();
70
71    // Make the temp file executable (Unix only)
72    #[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    // Execute the processed script with the remaining arguments
81    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    // Clean up the temp file
89    temp_path.close()?;
90
91    // Exit with the same code as the script
92    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                // Convert regular comments to echo statements
100                let indent = get_indent(line);
101                format!("{}echo \"{}\"", indent, escape_for_echo(&content))
102            }
103            CommentType::NoEcho(content) => {
104                // Keep ## comments as regular comments (remove one #)
105                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                // Convert #\# to echo "# content"
114                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        // Keep other lines as-is
125        line.to_string()
126    }
127}
128
129fn process_echo_to_comment(line: &str) -> String {
130    if let Some(echo_content) = extract_echo(line) {
131        // Convert echo statements to comments
132        let indent = get_indent(line);
133
134        // Check if the echo content starts with "# " - if so, escape it
135        if let Some(content) = echo_content.strip_prefix("# ") {
136            // Remove "# " prefix
137            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        // Keep other lines as-is
151        line.to_string()
152    }
153}
154
155#[derive(Debug, PartialEq)]
156enum CommentType {
157    Regular(String),     // # comment -> echo "comment"
158    NoEcho(String),      // ## comment -> # comment (no echo)
159    EscapedHash(String), // #\# comment -> echo "# comment"
160}
161
162fn extract_comment(line: &str) -> Option<CommentType> {
163    let trimmed = line.trim_start();
164
165    // Check for #\# (escaped hash)
166    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    // Check for ## (no-echo comment)
173    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    // Check for regular # comment
180    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    // Match exactly "echo" followed by end-of-string or whitespace
193    if trimmed == "echo" {
194        return Some(String::new());
195    }
196
197    // Match "echo " (with space) for content after
198    if let Some(rest) = trimmed.strip_prefix("echo ") {
199        let content = rest.trim();
200
201        // Handle quoted strings
202        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            // Unquoted echo - take everything after "echo "
211            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        // Regular comments (will be echoed)
243        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        // No-echo comments (## -> #)
257        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        // Escaped hash comments (#\# -> echo "#")
271        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        // Non-comments
285        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        // Edge cases
303        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        // Regular comments become echoes
357        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        // ## comments become # comments (no echo)
365        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        // #\# comments become echo "# content"
373        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        // Non-comments stay the same
384        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        // Regular echoes become comments
391        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        // Echoes that start with "# " become #\# comments
396        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        // Non-echoes stay the same
407        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        // Regular comment -> echo -> comment
417        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        // No-echo comment -> stays comment
424        let no_echo_comment = "    ## Private note";
425        let processed = process_comment_to_echo(no_echo_comment);
426        assert_eq!(processed, "    # Private note");
427
428        // Escaped hash comment -> echo with hash -> escaped comment
429        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        // Test comments with special bash characters
439        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        // Test whitespace handling in extracted content
461        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        // Test tab indentation
475        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}