1use crate::config::Config;
2use crate::debug;
3use crate::error::Result;
4
5#[derive(Debug)]
6pub enum Mode {
7 CommentToEcho, EchoToComment, }
10
11#[derive(Debug, PartialEq)]
12enum CommentType {
13 Regular(String), NoEcho(String), EscapedHash(String), }
17
18pub 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
24pub 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 result.push_str(&config.shebang());
34 result.push('\n');
35
36 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 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 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" } 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 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 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 line.to_string()
116 }
117}
118
119fn process_echo_to_comment(line: &str) -> String {
120 if let Some(echo_content) = extract_echo(line) {
121 let indent = get_indent(line);
123
124 let clean_content = strip_color_codes(&echo_content);
126
127 if let Some(content) = clean_content.strip_prefix("# ") {
129 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 line.to_string()
145 }
146}
147
148fn extract_comment(line: &str) -> Option<CommentType> {
149 let trimmed = line.trim_start();
150
151 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 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 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 if trimmed == "echo" || trimmed == "echo -e" {
180 return Some(String::new());
181 }
182
183 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 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 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
212fn escape_for_echo(text: &str) -> String {
215 text.replace('"', "\\\"")
216}
217
218fn unescape_from_echo(text: &str) -> String {
220 text.replace("\\\"", "\"")
221}
222
223fn strip_color_codes(text: &str) -> String {
225 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 chars.next(); for ch in chars.by_ref() {
234 if ch.is_ascii_alphabetic() {
235 break; }
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 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 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 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 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 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", "text with `commands`", "text with \\backslashes", "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 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 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 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 assert_eq!(
437 process_echo_to_comment("echo -e \"\x1b[0;32mgreen\x1b[0m\""),
438 "# green"
439 );
440
441 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 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}