echo_comment/
processor.rs

1use crate::config::Config;
2use crate::debug;
3use crate::error::Result;
4
5#[derive(Debug)]
6pub enum Mode {
7    CommentToEcho, // echo-comment: comments become echo statements
8    EchoToComment, // comment-echo: echo statements become comments
9}
10
11#[derive(Debug, PartialEq)]
12enum CommentType {
13    Regular(String),     // # comment -> echo "comment"
14    NoEcho(String),      // ## comment -> # comment (no echo)
15    EscapedHash(String), // #\# comment -> echo "# comment"
16}
17
18/// Process script content by converting between comments and echo statements
19pub fn process_script_content(content: &str, mode: Mode) -> Result<String> {
20    let config = Config::from_env();
21    process_script_content_with_config(content, mode, &config)
22}
23
24/// Process script content with explicit configuration
25pub fn process_script_content_with_config(
26    content: &str,
27    mode: Mode,
28    config: &Config,
29) -> Result<String> {
30    let mut result = String::new();
31
32    // Use configured shebang
33    result.push_str(&config.shebang());
34    result.push('\n');
35
36    // Process each line based on mode
37    let mut is_first_line = true;
38    for (i, line) in content.lines().enumerate() {
39        debug!("Processing line {}: '{}'", i, line);
40        if is_first_line && line.starts_with("#!") {
41            // Skip original shebang - we already added our own
42            debug!("DEBUG: Skipping first-line shebang: {}", line);
43            is_first_line = false;
44            continue;
45        }
46        is_first_line = false;
47
48        let processed_line = match mode {
49            Mode::CommentToEcho => process_comment_to_echo(line, config),
50            Mode::EchoToComment => process_echo_to_comment(line),
51        };
52
53        debug!("'{}' -> '{}'", line, processed_line);
54
55        result.push_str(&processed_line);
56        result.push('\n');
57    }
58
59    debug!("Finished processing all lines");
60    Ok(result)
61}
62
63fn process_comment_to_echo(line: &str, config: &Config) -> String {
64    if let Some(comment) = extract_comment(line) {
65        match comment {
66            CommentType::Regular(content) => {
67                // Convert regular comments to echo statements with optional color
68                let indent = get_indent(line);
69                let colored_content = config.colorize(&content);
70                let echo_cmd = if config.comment_color.is_some() {
71                    "echo -e" // Use -e for escape sequences when we have colors
72                } else {
73                    "echo"
74                };
75                format!(
76                    "{}{} \"{}\"",
77                    indent,
78                    echo_cmd,
79                    escape_for_echo(&colored_content)
80                )
81            }
82            CommentType::NoEcho(content) => {
83                // Keep ## comments as regular comments (remove one #)
84                let indent = get_indent(line);
85                if content.is_empty() {
86                    format!("{}#", indent)
87                } else {
88                    format!("{}# {}", indent, content)
89                }
90            }
91            CommentType::EscapedHash(content) => {
92                // Convert #\# to echo "# content"
93                let indent = get_indent(line);
94                let echo_content = if content.is_empty() {
95                    "#".to_string()
96                } else {
97                    format!("# {}", content)
98                };
99                let colored_content = config.colorize(&echo_content);
100                let echo_cmd = if config.comment_color.is_some() {
101                    "echo -e"
102                } else {
103                    "echo"
104                };
105                format!(
106                    "{}{} \"{}\"",
107                    indent,
108                    echo_cmd,
109                    escape_for_echo(&colored_content)
110                )
111            }
112        }
113    } else {
114        // Keep other lines as-is
115        line.to_string()
116    }
117}
118
119fn process_echo_to_comment(line: &str) -> String {
120    if let Some(echo_content) = extract_echo(line) {
121        // Convert echo statements to comments
122        let indent = get_indent(line);
123
124        // Strip color codes when converting back to comments
125        let clean_content = strip_color_codes(&echo_content);
126
127        // Check if the echo content starts with "# " - if so, escape it
128        if let Some(content) = clean_content.strip_prefix("# ") {
129            // Remove "# " prefix
130            if content.is_empty() {
131                format!("{}#\\#", indent)
132            } else {
133                format!("{}#\\# {}", indent, content)
134            }
135        } else if clean_content == "#" {
136            format!("{}#\\#", indent)
137        } else if clean_content.is_empty() {
138            format!("{}#", indent)
139        } else {
140            format!("{}# {}", indent, clean_content)
141        }
142    } else {
143        // Keep other lines as-is
144        line.to_string()
145    }
146}
147
148fn extract_comment(line: &str) -> Option<CommentType> {
149    let trimmed = line.trim_start();
150
151    // Check for #\# (escaped hash)
152    if let Some(rest) = trimmed.strip_prefix("#\\# ") {
153        return Some(CommentType::EscapedHash(rest.trim_start().to_string()));
154    } else if trimmed == "#\\#" {
155        return Some(CommentType::EscapedHash(String::new()));
156    }
157
158    // Check for ## (no-echo comment)
159    if let Some(rest) = trimmed.strip_prefix("## ") {
160        return Some(CommentType::NoEcho(rest.trim_start().to_string()));
161    } else if trimmed == "##" {
162        return Some(CommentType::NoEcho(String::new()));
163    }
164
165    // Check for regular # comment
166    if let Some(rest) = trimmed.strip_prefix("# ") {
167        return Some(CommentType::Regular(rest.trim_start().to_string()));
168    } else if trimmed == "#" {
169        return Some(CommentType::Regular(String::new()));
170    }
171
172    None
173}
174
175fn extract_echo(line: &str) -> Option<String> {
176    let trimmed = line.trim_start();
177
178    // Match exactly "echo" or "echo -e" followed by end-of-string or whitespace
179    if trimmed == "echo" || trimmed == "echo -e" {
180        return Some(String::new());
181    }
182
183    // Match "echo " or "echo -e " (with space) for content after
184    let content = if let Some(rest) = trimmed.strip_prefix("echo -e ") {
185        rest
186    } else if let Some(rest) = trimmed.strip_prefix("echo ") {
187        rest
188    } else {
189        return None;
190    };
191
192    let content = content.trim();
193
194    // Handle quoted strings
195    if (content.starts_with('"') && content.ends_with('"'))
196        || (content.starts_with('\'') && content.ends_with('\''))
197    {
198        let inner = &content[1..content.len() - 1];
199        Some(unescape_from_echo(inner))
200    } else if content.is_empty() {
201        Some(String::new())
202    } else {
203        // Unquoted echo - take everything after "echo "
204        Some(content.to_string())
205    }
206}
207
208fn get_indent(line: &str) -> String {
209    line.chars().take_while(|c| c.is_whitespace()).collect()
210}
211
212/// Only escape double quotes to keep the echo syntactically valid
213/// Leave $, `, and \ alone so they can be expanded/interpreted by the shell
214fn escape_for_echo(text: &str) -> String {
215    text.replace('"', "\\\"")
216}
217
218/// Only unescape double quotes to match the escaping above
219fn unescape_from_echo(text: &str) -> String {
220    text.replace("\\\"", "\"")
221}
222
223/// Strip ANSI color codes from text
224fn strip_color_codes(text: &str) -> String {
225    // Simple regex-free approach to strip ANSI escape sequences
226    let mut result = String::new();
227    let mut chars = text.chars().peekable();
228
229    while let Some(ch) = chars.next() {
230        if ch == '\x1b' && chars.peek() == Some(&'[') {
231            // Skip the escape sequence
232            chars.next(); // consume '['
233            for ch in chars.by_ref() {
234                if ch.is_ascii_alphabetic() {
235                    break; // End of escape sequence
236                }
237            }
238        } else {
239            result.push(ch);
240        }
241    }
242
243    result
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_extract_comment_types() {
252        // Regular comments (will be echoed)
253        assert_eq!(
254            extract_comment("# hello world"),
255            Some(CommentType::Regular("hello world".to_string()))
256        );
257        assert_eq!(
258            extract_comment("    # indented comment"),
259            Some(CommentType::Regular("indented comment".to_string()))
260        );
261        assert_eq!(
262            extract_comment("#"),
263            Some(CommentType::Regular(String::new()))
264        );
265
266        // No-echo comments (## -> #)
267        assert_eq!(
268            extract_comment("## private comment"),
269            Some(CommentType::NoEcho("private comment".to_string()))
270        );
271        assert_eq!(
272            extract_comment("  ## indented private"),
273            Some(CommentType::NoEcho("indented private".to_string()))
274        );
275        assert_eq!(
276            extract_comment("##"),
277            Some(CommentType::NoEcho(String::new()))
278        );
279
280        // Escaped hash comments (#\# -> echo "#")
281        assert_eq!(
282            extract_comment("#\\# with hash"),
283            Some(CommentType::EscapedHash("with hash".to_string()))
284        );
285        assert_eq!(
286            extract_comment("  #\\# indented hash"),
287            Some(CommentType::EscapedHash("indented hash".to_string()))
288        );
289        assert_eq!(
290            extract_comment("#\\#"),
291            Some(CommentType::EscapedHash(String::new()))
292        );
293
294        // Non-comments
295        assert_eq!(extract_comment("echo hello"), None);
296        assert_eq!(extract_comment("#!/bin/bash"), None);
297        assert_eq!(extract_comment("#no space"), None);
298        assert_eq!(extract_comment("not # a comment"), None);
299    }
300
301    #[test]
302    fn test_extract_echo() {
303        assert_eq!(
304            extract_echo("echo \"hello world\""),
305            Some("hello world".to_string())
306        );
307        assert_eq!(
308            extract_echo("echo -e \"colored text\""),
309            Some("colored text".to_string())
310        );
311        assert_eq!(extract_echo("    echo 'test'"), Some("test".to_string()));
312        assert_eq!(extract_echo("echo"), Some(String::new()));
313        assert_eq!(extract_echo("echo -e"), Some(String::new()));
314        assert_eq!(extract_echo("# comment"), None);
315        assert_eq!(extract_echo("ls -la"), None);
316
317        // Edge cases
318        assert_eq!(extract_echo("echo "), Some(String::new()));
319        assert_eq!(extract_echo("echo -e "), Some(String::new()));
320        assert_eq!(
321            extract_echo("echo unquoted text"),
322            Some("unquoted text".to_string())
323        );
324        assert_eq!(extract_echo("echo \"\""), Some(String::new()));
325        assert_eq!(extract_echo("echo ''"), Some(String::new()));
326        assert_eq!(
327            extract_echo("echo \"with \\\"escaped\\\" quotes\""),
328            Some("with \"escaped\" quotes".to_string())
329        );
330        assert_eq!(
331            extract_echo("echo \"$var and `cmd`\""),
332            Some("$var and `cmd`".to_string())
333        );
334        assert_eq!(
335            extract_echo("  echo  \"  spaced  \"  "),
336            Some("  spaced  ".to_string())
337        );
338        assert_eq!(extract_echo("echoing"), None);
339        assert_eq!(extract_echo("echo-like"), None);
340    }
341
342    #[test]
343    fn test_get_indent() {
344        assert_eq!(get_indent("# comment"), "");
345        assert_eq!(get_indent("    # indented"), "    ");
346        assert_eq!(get_indent("\t# tabbed"), "\t");
347        assert_eq!(get_indent("  \t  mixed"), "  \t  ");
348        assert_eq!(get_indent("no indent"), "");
349    }
350
351    #[test]
352    fn test_escape_unescape_roundtrip() {
353        let test_cases = vec![
354            "simple text",
355            "text with \"quotes\"",
356            "text with $variables",    // These should NOT be escaped
357            "text with `commands`",    // These should NOT be escaped
358            "text with \\backslashes", // These should NOT be escaped
359            "complex: \"$var\" and `echo test` with \\path",
360            "",
361        ];
362
363        for original in test_cases {
364            let escaped = escape_for_echo(original);
365            let unescaped = unescape_from_echo(&escaped);
366            assert_eq!(original, unescaped, "Failed roundtrip for: {}", original);
367        }
368
369        // Test that only quotes are escaped
370        assert_eq!(escape_for_echo("$var"), "$var");
371        assert_eq!(escape_for_echo("`cmd`"), "`cmd`");
372        assert_eq!(escape_for_echo("\\path"), "\\path");
373        assert_eq!(escape_for_echo("\"quoted\""), r#"\"quoted\""#);
374    }
375
376    #[test]
377    fn test_strip_color_codes() {
378        assert_eq!(strip_color_codes("plain text"), "plain text");
379        assert_eq!(strip_color_codes("\x1b[0;32mgreen\x1b[0m"), "green");
380        assert_eq!(
381            strip_color_codes("\x1b[1;31mred\x1b[0m and \x1b[0;34mblue\x1b[0m"),
382            "red and blue"
383        );
384        assert_eq!(strip_color_codes("\x1b[0m"), "");
385    }
386
387    #[test]
388    fn test_process_comment_to_echo_with_color() {
389        let config = Config {
390            shell: "bash".to_string(),
391            shell_flags: vec![],
392            comment_color: Some("\x1b[0;32m".to_string()),
393        };
394
395        assert_eq!(
396            process_comment_to_echo("# test", &config),
397            "echo -e \"\x1b[0;32mtest\x1b[0m\""
398        );
399        assert_eq!(
400            process_comment_to_echo("  # indented", &config),
401            "  echo -e \"\x1b[0;32mindented\x1b[0m\""
402        );
403    }
404
405    #[test]
406    fn test_process_comment_to_echo_without_color() {
407        let config = Config::default();
408
409        assert_eq!(process_comment_to_echo("# test", &config), "echo \"test\"");
410        assert_eq!(
411            process_comment_to_echo("  # indented", &config),
412            "  echo \"indented\""
413        );
414    }
415
416    #[test]
417    fn test_process_echo_to_comment() {
418        // Regular echoes become comments
419        assert_eq!(process_echo_to_comment("echo \"test\""), "# test");
420        assert_eq!(process_echo_to_comment("  echo 'indented'"), "  # indented");
421        assert_eq!(process_echo_to_comment("echo"), "#");
422        assert_eq!(process_echo_to_comment("echo -e"), "#");
423
424        // Echoes that start with "# " become #\# comments
425        assert_eq!(
426            process_echo_to_comment("echo \"# with hash\""),
427            "#\\# with hash"
428        );
429        assert_eq!(
430            process_echo_to_comment("  echo '# indented hash'"),
431            "  #\\# indented hash"
432        );
433        assert_eq!(process_echo_to_comment("echo \"#\""), "#\\#");
434
435        // Colored echoes get stripped
436        assert_eq!(
437            process_echo_to_comment("echo -e \"\x1b[0;32mgreen\x1b[0m\""),
438            "# green"
439        );
440
441        // Non-echoes stay the same
442        assert_eq!(process_echo_to_comment("not an echo"), "not an echo");
443        assert_eq!(
444            process_echo_to_comment("# already comment"),
445            "# already comment"
446        );
447    }
448
449    #[test]
450    fn test_bidirectional_conversion_with_config() {
451        let config = Config {
452            shell: "bash".to_string(),
453            shell_flags: vec![],
454            comment_color: Some("\x1b[0;32m".to_string()),
455        };
456
457        // Regular comment -> echo -> comment
458        let comment_line = "    # Hello world";
459        let echo_line = process_comment_to_echo(comment_line, &config);
460        assert_eq!(echo_line, "    echo -e \"\x1b[0;32mHello world\x1b[0m\"");
461        let back_to_comment = process_echo_to_comment(&echo_line);
462        assert_eq!(back_to_comment, "    # Hello world");
463    }
464
465    #[test]
466    fn test_process_script_content_with_config() {
467        let config = Config {
468            shell: "zsh".to_string(),
469            shell_flags: vec!["-euo".to_string(), "pipefail".to_string()],
470            comment_color: Some("\x1b[0;32m".to_string()),
471        };
472
473        let input = "#!/usr/bin/env bash\n# test comment\necho existing\n## private note";
474
475        let result =
476            process_script_content_with_config(input, Mode::CommentToEcho, &config).unwrap();
477        let expected = "#!/usr/bin/env -S zsh -euo pipefail\necho -e \"\x1b[0;32mtest comment\x1b[0m\"\necho existing\n# private note\n";
478        assert_eq!(result, expected);
479    }
480}