echo_comment/
processor.rs

1use crate::debug;
2use crate::error::Result;
3
4#[derive(Debug)]
5pub enum Mode {
6    CommentToEcho, // echo-comment: comments become echo statements
7    EchoToComment, // comment-echo: echo statements become comments
8}
9
10#[derive(Debug, PartialEq)]
11enum CommentType {
12    Regular(String),     // # comment -> echo "comment"
13    NoEcho(String),      // ## comment -> # comment (no echo)
14    EscapedHash(String), // #\# comment -> echo "# comment"
15}
16
17/// Process script content by converting between comments and echo statements
18pub fn process_script_content(content: &str, mode: Mode) -> Result<String> {
19    let mut result = String::new();
20
21    // Always start with a proper shebang
22    result.push_str("#!/usr/bin/env bash\n");
23
24    // Process each line based on mode
25    let mut is_first_line = true;
26    for (i, line) in content.lines().enumerate() {
27        debug!("Processing line {}: '{}'", i, line);
28        if is_first_line && line.starts_with("#!") {
29            // Skip original shebang - we already added our own
30            debug!("DEBUG: Skipping first-line shebang: {}", line);
31            is_first_line = false;
32            continue;
33        }
34        is_first_line = false;
35
36        let processed_line = match mode {
37            Mode::CommentToEcho => process_comment_to_echo(line),
38            Mode::EchoToComment => process_echo_to_comment(line),
39        };
40
41        debug!("'{}' -> '{}'", line, processed_line);
42
43        result.push_str(&processed_line);
44        result.push('\n');
45    }
46
47    debug!("Finished processing all lines");
48    Ok(result)
49}
50
51fn process_comment_to_echo(line: &str) -> String {
52    if let Some(comment) = extract_comment(line) {
53        match comment {
54            CommentType::Regular(content) => {
55                // Convert regular comments to echo statements
56                let indent = get_indent(line);
57                format!("{}echo \"{}\"", indent, escape_for_echo(&content))
58            }
59            CommentType::NoEcho(content) => {
60                // Keep ## comments as regular comments (remove one #)
61                let indent = get_indent(line);
62                if content.is_empty() {
63                    format!("{}#", indent)
64                } else {
65                    format!("{}# {}", indent, content)
66                }
67            }
68            CommentType::EscapedHash(content) => {
69                // Convert #\# to echo "# content"
70                let indent = get_indent(line);
71                let echo_content = if content.is_empty() {
72                    "#".to_string()
73                } else {
74                    format!("# {}", content)
75                };
76                format!("{}echo \"{}\"", indent, escape_for_echo(&echo_content))
77            }
78        }
79    } else {
80        // Keep other lines as-is
81        line.to_string()
82    }
83}
84
85fn process_echo_to_comment(line: &str) -> String {
86    if let Some(echo_content) = extract_echo(line) {
87        // Convert echo statements to comments
88        let indent = get_indent(line);
89
90        // Check if the echo content starts with "# " - if so, escape it
91        if let Some(content) = echo_content.strip_prefix("# ") {
92            // Remove "# " prefix
93            if content.is_empty() {
94                format!("{}#\\#", indent)
95            } else {
96                format!("{}#\\# {}", indent, content)
97            }
98        } else if echo_content == "#" {
99            format!("{}#\\#", indent)
100        } else if echo_content.is_empty() {
101            format!("{}#", indent)
102        } else {
103            format!("{}# {}", indent, echo_content)
104        }
105    } else {
106        // Keep other lines as-is
107        line.to_string()
108    }
109}
110
111fn extract_comment(line: &str) -> Option<CommentType> {
112    let trimmed = line.trim_start();
113
114    // Check for #\# (escaped hash)
115    if let Some(rest) = trimmed.strip_prefix("#\\# ") {
116        return Some(CommentType::EscapedHash(rest.trim_start().to_string()));
117    } else if trimmed == "#\\#" {
118        return Some(CommentType::EscapedHash(String::new()));
119    }
120
121    // Check for ## (no-echo comment)
122    if let Some(rest) = trimmed.strip_prefix("## ") {
123        return Some(CommentType::NoEcho(rest.trim_start().to_string()));
124    } else if trimmed == "##" {
125        return Some(CommentType::NoEcho(String::new()));
126    }
127
128    // Check for regular # comment
129    if let Some(rest) = trimmed.strip_prefix("# ") {
130        return Some(CommentType::Regular(rest.trim_start().to_string()));
131    } else if trimmed == "#" {
132        return Some(CommentType::Regular(String::new()));
133    }
134
135    None
136}
137
138fn extract_echo(line: &str) -> Option<String> {
139    let trimmed = line.trim_start();
140
141    // Match exactly "echo" followed by end-of-string or whitespace
142    if trimmed == "echo" {
143        return Some(String::new());
144    }
145
146    // Match "echo " (with space) for content after
147    if let Some(rest) = trimmed.strip_prefix("echo ") {
148        let content = rest.trim();
149
150        // Handle quoted strings
151        if (content.starts_with('"') && content.ends_with('"'))
152            || (content.starts_with('\'') && content.ends_with('\''))
153        {
154            let inner = &content[1..content.len() - 1];
155            Some(unescape_from_echo(inner))
156        } else if content.is_empty() {
157            Some(String::new())
158        } else {
159            // Unquoted echo - take everything after "echo "
160            Some(content.to_string())
161        }
162    } else {
163        None
164    }
165}
166
167fn get_indent(line: &str) -> String {
168    line.chars().take_while(|c| c.is_whitespace()).collect()
169}
170
171fn escape_for_echo(text: &str) -> String {
172    text.replace('\\', "\\\\")
173        .replace('"', "\\\"")
174        .replace('$', "\\$")
175        .replace('`', "\\`")
176}
177
178fn unescape_from_echo(text: &str) -> String {
179    text.replace("\\\"", "\"")
180        .replace("\\$", "$")
181        .replace("\\`", "`")
182        .replace("\\\\", "\\")
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_extract_comment_types() {
191        // Regular comments (will be echoed)
192        assert_eq!(
193            extract_comment("# hello world"),
194            Some(CommentType::Regular("hello world".to_string()))
195        );
196        assert_eq!(
197            extract_comment("    # indented comment"),
198            Some(CommentType::Regular("indented comment".to_string()))
199        );
200        assert_eq!(
201            extract_comment("#"),
202            Some(CommentType::Regular(String::new()))
203        );
204
205        // No-echo comments (## -> #)
206        assert_eq!(
207            extract_comment("## private comment"),
208            Some(CommentType::NoEcho("private comment".to_string()))
209        );
210        assert_eq!(
211            extract_comment("  ## indented private"),
212            Some(CommentType::NoEcho("indented private".to_string()))
213        );
214        assert_eq!(
215            extract_comment("##"),
216            Some(CommentType::NoEcho(String::new()))
217        );
218
219        // Escaped hash comments (#\# -> echo "#")
220        assert_eq!(
221            extract_comment("#\\# with hash"),
222            Some(CommentType::EscapedHash("with hash".to_string()))
223        );
224        assert_eq!(
225            extract_comment("  #\\# indented hash"),
226            Some(CommentType::EscapedHash("indented hash".to_string()))
227        );
228        assert_eq!(
229            extract_comment("#\\#"),
230            Some(CommentType::EscapedHash(String::new()))
231        );
232
233        // Non-comments
234        assert_eq!(extract_comment("echo hello"), None);
235        assert_eq!(extract_comment("#!/bin/bash"), None);
236        assert_eq!(extract_comment("#no space"), None);
237        assert_eq!(extract_comment("not # a comment"), None);
238    }
239
240    #[test]
241    fn test_extract_echo() {
242        assert_eq!(
243            extract_echo("echo \"hello world\""),
244            Some("hello world".to_string())
245        );
246        assert_eq!(extract_echo("    echo 'test'"), Some("test".to_string()));
247        assert_eq!(extract_echo("echo"), Some(String::new()));
248        assert_eq!(extract_echo("# comment"), None);
249        assert_eq!(extract_echo("ls -la"), None);
250
251        // Edge cases
252        assert_eq!(extract_echo("echo "), Some(String::new()));
253        assert_eq!(
254            extract_echo("echo unquoted text"),
255            Some("unquoted text".to_string())
256        );
257        assert_eq!(extract_echo("echo \"\""), Some(String::new()));
258        assert_eq!(extract_echo("echo ''"), Some(String::new()));
259        assert_eq!(
260            extract_echo("echo \"with \\\"escaped\\\" quotes\""),
261            Some("with \"escaped\" quotes".to_string())
262        );
263        assert_eq!(
264            extract_echo("echo \"$var and `cmd`\""),
265            Some("$var and `cmd`".to_string())
266        );
267        assert_eq!(
268            extract_echo("  echo  \"  spaced  \"  "),
269            Some("  spaced  ".to_string())
270        );
271        assert_eq!(extract_echo("echoing"), None);
272        assert_eq!(extract_echo("echo-like"), None);
273    }
274
275    #[test]
276    fn test_get_indent() {
277        assert_eq!(get_indent("# comment"), "");
278        assert_eq!(get_indent("    # indented"), "    ");
279        assert_eq!(get_indent("\t# tabbed"), "\t");
280        assert_eq!(get_indent("  \t  mixed"), "  \t  ");
281        assert_eq!(get_indent("no indent"), "");
282    }
283
284    #[test]
285    fn test_escape_unescape_roundtrip() {
286        let test_cases = vec![
287            "simple text",
288            "text with \"quotes\"",
289            "text with $variables",
290            "text with `commands`",
291            "text with \\backslashes",
292            "complex: \"$var\" and `echo test` with \\path",
293            "",
294        ];
295
296        for original in test_cases {
297            let escaped = escape_for_echo(original);
298            let unescaped = unescape_from_echo(&escaped);
299            assert_eq!(original, unescaped, "Failed roundtrip for: {}", original);
300        }
301    }
302
303    #[test]
304    fn test_process_comment_to_echo() {
305        // Regular comments become echoes
306        assert_eq!(process_comment_to_echo("# test"), "echo \"test\"");
307        assert_eq!(
308            process_comment_to_echo("  # indented"),
309            "  echo \"indented\""
310        );
311        assert_eq!(process_comment_to_echo("#"), "echo \"\"");
312
313        // ## comments become # comments (no echo)
314        assert_eq!(process_comment_to_echo("## private"), "# private");
315        assert_eq!(
316            process_comment_to_echo("  ## indented private"),
317            "  # indented private"
318        );
319        assert_eq!(process_comment_to_echo("##"), "#");
320
321        // #\# comments become echo "# content"
322        assert_eq!(
323            process_comment_to_echo("#\\# with hash"),
324            "echo \"# with hash\""
325        );
326        assert_eq!(
327            process_comment_to_echo("  #\\# indented hash"),
328            "  echo \"# indented hash\""
329        );
330        assert_eq!(process_comment_to_echo("#\\#"), "echo \"#\"");
331
332        // Non-comments stay the same
333        assert_eq!(process_comment_to_echo("not a comment"), "not a comment");
334        assert_eq!(process_comment_to_echo("echo already"), "echo already");
335    }
336
337    #[test]
338    fn test_process_echo_to_comment() {
339        // Regular echoes become comments
340        assert_eq!(process_echo_to_comment("echo \"test\""), "# test");
341        assert_eq!(process_echo_to_comment("  echo 'indented'"), "  # indented");
342        assert_eq!(process_echo_to_comment("echo"), "#");
343
344        // Echoes that start with "# " become #\# comments
345        assert_eq!(
346            process_echo_to_comment("echo \"# with hash\""),
347            "#\\# with hash"
348        );
349        assert_eq!(
350            process_echo_to_comment("  echo '# indented hash'"),
351            "  #\\# indented hash"
352        );
353        assert_eq!(process_echo_to_comment("echo \"#\""), "#\\#");
354
355        // Non-echoes stay the same
356        assert_eq!(process_echo_to_comment("not an echo"), "not an echo");
357        assert_eq!(
358            process_echo_to_comment("# already comment"),
359            "# already comment"
360        );
361    }
362
363    #[test]
364    fn test_bidirectional_conversion() {
365        // Regular comment -> echo -> comment
366        let comment_line = "    # Hello world";
367        let echo_line = process_comment_to_echo(comment_line);
368        assert_eq!(echo_line, "    echo \"Hello world\"");
369        let back_to_comment = process_echo_to_comment(&echo_line);
370        assert_eq!(back_to_comment, "    # Hello world");
371
372        // No-echo comment -> stays comment
373        let no_echo_comment = "    ## Private note";
374        let processed = process_comment_to_echo(no_echo_comment);
375        assert_eq!(processed, "    # Private note");
376
377        // Escaped hash comment -> echo with hash -> escaped comment
378        let escaped_comment = "    #\\# Show hash";
379        let echo_with_hash = process_comment_to_echo(escaped_comment);
380        assert_eq!(echo_with_hash, "    echo \"# Show hash\"");
381        let back_to_escaped = process_echo_to_comment(&echo_with_hash);
382        assert_eq!(back_to_escaped, "    #\\# Show hash");
383    }
384
385    #[test]
386    fn test_process_script_content() {
387        let input = "#!/usr/bin/env bash\n# test comment\necho existing\n## private note";
388
389        let result = process_script_content(input, Mode::CommentToEcho).unwrap();
390        let expected =
391            "#!/usr/bin/env bash\necho \"test comment\"\necho existing\n# private note\n";
392        assert_eq!(result, expected);
393
394        let result = process_script_content(input, Mode::EchoToComment).unwrap();
395        let expected = "#!/usr/bin/env bash\n# test comment\n# existing\n## private note\n";
396        assert_eq!(result, expected);
397    }
398}