Skip to main content

cargo_mate/
strip.rs

1use anyhow::Result;
2use clap::Parser;
3use std::fs;
4use std::path::PathBuf;
5use walkdir::WalkDir;
6#[derive(Parser, Debug, Clone)]
7#[command(
8    name = "strip",
9    about = "Strip comments, blank lines, and attributes from Rust source code",
10    long_about = r#"Remove comments, blank lines, attributes, and other non-essential elements from Rust source code.
11
12MODES:
13    Basic:    Remove comments and optionally blank lines
14    Minify:   Single-line output where possible
15    Aggressive: Maximum stripping - removes attributes, docs, and compresses whitespace
16
17BACKUP SAFETY:
18    ✅ By default, backups are created in ~/.shipwreck/strip/
19    ❌ Use --no-backup to disable backups
20    ⚠️ Use --force to allow overwriting the same file
21
22EXAMPLES:
23    cm strip src/main.rs                           # Basic stripping to stdout (with backup)
24    cm strip src/main.rs --output main.stripped.rs # Strip to new file (with backup)
25    cm strip src/ -r                              # Process directory (with backups)
26    cm strip src/ -r -a                           # Aggressive stripping (with backups)
27    cm strip main.rs --minify                     # Single-line output (with backup)
28    cm strip src/ -r --strip-attrs --strip-docs   # Remove specific elements (with backups)
29    cm strip src/ -r --no-backup                  # Process without backups (dangerous!)"#
30)]
31pub struct StripArgs {
32    pub input: PathBuf,
33    #[arg(short, long)]
34    pub output: Option<PathBuf>,
35    #[arg(long)]
36    pub target: Option<PathBuf>,
37    #[arg(short = 'b', long)]
38    pub remove_blanks: bool,
39    #[arg(short, long)]
40    pub recursive: bool,
41    #[arg(long)]
42    pub force: bool,
43    #[arg(long)]
44    pub no_backup: bool,
45    #[arg(long)]
46    pub src: bool,
47    #[arg(long, default_value = "10")]
48    pub max_depth: usize,
49    #[arg(long, short = 'a')]
50    pub aggressive: bool,
51    #[arg(long)]
52    pub minify: bool,
53    #[arg(long, short = 't')]
54    pub tease: bool,
55    #[arg(long)]
56    pub strip_attrs: bool,
57    #[arg(long)]
58    pub strip_docs: bool,
59    #[arg(long)]
60    pub inline_uses: bool,
61    #[arg(short = 'v', long)]
62    pub verbose: bool,
63}
64pub fn handle_strip_command(args: StripArgs) -> Result<()> {
65    show_active_options(&args);
66    let input_path = determine_input_path(&args)?;
67    if !input_path.exists() {
68        return Err(
69            anyhow::anyhow!("Input path does not exist: {}", input_path.display()),
70        );
71    }
72    // SAFETY: Validate that we're only processing Rust files
73    // This prevents accidental processing of Cargo.toml, .json, or other files
74    if !input_path.is_dir() {
75        validate_rust_file(&input_path)?;
76    }
77
78    // Extra safety check: warn about critical files in directory
79    if input_path.is_dir() {
80        println!("⚠️  Processing directory: {}", input_path.display());
81        println!("   Only .rs files will be processed, all other files are skipped");
82    }
83
84    let backup_dir = create_backup_directory()?;
85    if args.recursive || input_path.is_dir() {
86        process_directory(&input_path, &args, &backup_dir)?;
87    } else {
88        process_single_file(&input_path, &args, args.output.as_ref(), &backup_dir)?;
89    }
90    Ok(())
91}
92fn show_active_options(args: &StripArgs) {
93    let mut options = Vec::new();
94    if args.tease {
95        options.push("🌶️ TEASE mode (remove all comments + blanks)");
96    } else if args.aggressive {
97        options.push("🔥 Aggressive mode");
98    } else {
99        if args.remove_blanks {
100            options.push("📝 Remove blank lines");
101        }
102        if args.strip_attrs {
103            options.push("🏷️  Strip attributes");
104        }
105        if args.strip_docs {
106            options.push("📖 Strip doc comments");
107        }
108        if args.minify {
109            options.push("🎯 Minify output");
110        }
111        if args.inline_uses {
112            options.push("🔗 Inline use statements");
113        }
114    }
115    if args.no_backup {
116        options.push("❌ No backups");
117    } else {
118        options.push("💾 Auto-backup (default)");
119    }
120    if args.force {
121        options.push("⚠️  Force overwrite");
122    }
123    if args.recursive {
124        options.push("📁 Recursive");
125    }
126    if !options.is_empty() {
127        println!("🚀 Active options:");
128        for option in options {
129            println!("   {}", option);
130        }
131        println!();
132    }
133}
134fn determine_input_path(args: &StripArgs) -> Result<PathBuf> {
135    if args.src {
136        Ok(PathBuf::from("src"))
137    } else if let Some(target) = &args.target {
138        Ok(target.clone())
139    } else {
140        Ok(args.input.clone())
141    }
142}
143fn validate_rust_file(path: &PathBuf) -> Result<()> {
144    let extension = path.extension().and_then(|s| s.to_str());
145    if extension != Some("rs") {
146        return Err(anyhow::anyhow!(
147            "❌ Can only strip Rust (.rs) files. Got: {:?}\n   File: {}",
148            extension.unwrap_or("no extension"),
149            path.display()
150        ));
151    }
152    Ok(())
153}
154
155fn create_backup_directory() -> Result<PathBuf> {
156    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
157    let backup_dir = PathBuf::from(home).join(".shipwreck").join("strip");
158    if !backup_dir.exists() {
159        fs::create_dir_all(&backup_dir)?;
160        println!("📁 Created backup directory: {}", backup_dir.display());
161    }
162    Ok(backup_dir)
163}
164fn create_backup(original_path: &PathBuf, backup_dir: &PathBuf) -> Result<PathBuf> {
165    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
166    let file_name = original_path
167        .file_name()
168        .and_then(|n| n.to_str())
169        .unwrap_or("unknown");
170    let backup_name = format!("{}_{}.backup", file_name, timestamp);
171    let backup_path = backup_dir.join(backup_name);
172    fs::copy(original_path, &backup_path)?;
173    println!("🔄 Backup created: {}", backup_path.display());
174    Ok(backup_path)
175}
176fn strip_rust(source: &str, args: &StripArgs) -> Result<String> {
177    let source_to_parse = if args.tease {
178        strip_all_comments_manual(source)
179    } else {
180        source.to_string()
181    };
182    let mut syntax_tree = syn::parse_file(&source_to_parse)?;
183    if args.aggressive || args.strip_attrs {
184        strip_attributes(&mut syntax_tree);
185    }
186    if args.aggressive || args.strip_docs {
187        strip_doc_comments(&mut syntax_tree);
188    }
189    if args.aggressive || args.inline_uses {
190        inline_use_statements(&mut syntax_tree);
191    }
192    let mut output = if args.minify || args.aggressive {
193        prettyplease::unparse(&syntax_tree)
194            .split_whitespace()
195            .collect::<Vec<_>>()
196            .join(" ")
197    } else {
198        prettyplease::unparse(&syntax_tree)
199    };
200    if args.aggressive {
201        output = output
202            .replace(" ;", ";")
203            .replace(" ,", ",")
204            .replace(" :", ":")
205            .replace(" {", "{")
206            .replace("{ ", "{")
207            .replace(" }", "}")
208            .replace("} ", "}")
209            .replace(" (", "(")
210            .replace("( ", "(")
211            .replace(" )", ")")
212            .replace(") ", ")")
213            .replace(" ->", "->")
214            .replace("-> ", "->");
215    }
216    if args.tease || args.remove_blanks {
217        let lines: Vec<String> = output.lines().map(|line| line.to_string()).collect();
218        let mut filtered_lines = Vec::new();
219        let mut prev_empty = false;
220        for line in lines {
221            let is_empty = line.trim().is_empty();
222            if !is_empty || !prev_empty {
223                filtered_lines.push(line);
224            }
225            prev_empty = is_empty;
226        }
227        output = filtered_lines.join("\n");
228    }
229    Ok(output)
230}
231fn strip_attributes(syntax_tree: &mut syn::File) {
232    use syn::visit_mut::{self, VisitMut};
233    struct AttrStripper;
234    impl VisitMut for AttrStripper {
235        fn visit_item_mut(&mut self, item: &mut syn::Item) {
236            match item {
237                syn::Item::Fn(f) => {
238                    f.attrs
239                        .retain(|attr| {
240                            attr.path().is_ident("test") || attr.path().is_ident("cfg")
241                        });
242                }
243                _ => {
244                    if let Some(attrs) = get_attrs_mut(item) {
245                        attrs.clear();
246                    }
247                }
248            }
249            visit_mut::visit_item_mut(self, item);
250        }
251    }
252    AttrStripper.visit_file_mut(syntax_tree);
253}
254fn strip_doc_comments(syntax_tree: &mut syn::File) {
255    use syn::visit_mut::{self, VisitMut};
256    struct DocStripper;
257    impl VisitMut for DocStripper {
258        fn visit_item_mut(&mut self, item: &mut syn::Item) {
259            match item {
260                syn::Item::Fn(f) => {
261                    f.attrs.retain(|attr| !is_doc_attr(attr));
262                }
263                syn::Item::Struct(s) => {
264                    s.attrs.retain(|attr| !is_doc_attr(attr));
265                }
266                syn::Item::Enum(e) => {
267                    e.attrs.retain(|attr| !is_doc_attr(attr));
268                }
269                syn::Item::Trait(t) => {
270                    t.attrs.retain(|attr| !is_doc_attr(attr));
271                }
272                syn::Item::Impl(i) => {
273                    i.attrs.retain(|attr| !is_doc_attr(attr));
274                }
275                syn::Item::Mod(m) => {
276                    m.attrs.retain(|attr| !is_doc_attr(attr));
277                }
278                syn::Item::Type(t) => {
279                    t.attrs.retain(|attr| !is_doc_attr(attr));
280                }
281                syn::Item::Const(c) => {
282                    c.attrs.retain(|attr| !is_doc_attr(attr));
283                }
284                syn::Item::Static(s) => {
285                    s.attrs.retain(|attr| !is_doc_attr(attr));
286                }
287                _ => {
288                    if let Some(attrs) = get_attrs_mut(item) {
289                        attrs.retain(|attr| !is_doc_attr(attr));
290                    }
291                }
292            }
293            visit_mut::visit_item_mut(self, item);
294        }
295    }
296    DocStripper.visit_file_mut(syntax_tree);
297}
298fn inline_use_statements(_syntax_tree: &mut syn::File) {}
299fn strip_all_comments_manual(source: &str) -> String {
300    let mut result = String::new();
301    let mut chars = source.chars().peekable();
302    let mut in_string = false;
303    let mut in_char = false;
304    let mut escape_next = false;
305    while let Some(ch) = chars.next() {
306        if escape_next {
307            result.push(ch);
308            escape_next = false;
309            continue;
310        }
311        if ch == '\\' && (in_string || in_char) {
312            result.push(ch);
313            escape_next = true;
314            continue;
315        }
316        if ch == '"' && !in_char {
317            in_string = !in_string;
318            result.push(ch);
319            continue;
320        }
321        if ch == '\'' && !in_string {
322            in_char = !in_char;
323            result.push(ch);
324            continue;
325        }
326        if !in_string && !in_char {
327            if ch == '/' {
328                if let Some(&next_ch) = chars.peek() {
329                    if next_ch == '/' {
330                        chars.next();
331                        while let Some(comment_ch) = chars.next() {
332                            if comment_ch == '\n' {
333                                result.push('\n');
334                                break;
335                            }
336                        }
337                        continue;
338                    } else if next_ch == '*' {
339                        chars.next();
340                        let mut prev_ch = ' ';
341                        while let Some(comment_ch) = chars.next() {
342                            if prev_ch == '*' && comment_ch == '/' {
343                                break;
344                            }
345                            prev_ch = comment_ch;
346                        }
347                        result.push(' ');
348                        continue;
349                    }
350                }
351            }
352        }
353        result.push(ch);
354    }
355    result
356}
357fn is_doc_attr(attr: &syn::Attribute) -> bool {
358    attr.path().is_ident("doc")
359}
360fn get_attrs_mut(item: &mut syn::Item) -> Option<&mut Vec<syn::Attribute>> {
361    match item {
362        syn::Item::Const(item) => Some(&mut item.attrs),
363        syn::Item::Enum(item) => Some(&mut item.attrs),
364        syn::Item::ExternCrate(item) => Some(&mut item.attrs),
365        syn::Item::Fn(item) => Some(&mut item.attrs),
366        syn::Item::ForeignMod(item) => Some(&mut item.attrs),
367        syn::Item::Impl(item) => Some(&mut item.attrs),
368        syn::Item::Macro(item) => Some(&mut item.attrs),
369        syn::Item::Mod(item) => Some(&mut item.attrs),
370        syn::Item::Static(item) => Some(&mut item.attrs),
371        syn::Item::Struct(item) => Some(&mut item.attrs),
372        syn::Item::Trait(item) => Some(&mut item.attrs),
373        syn::Item::TraitAlias(item) => Some(&mut item.attrs),
374        syn::Item::Type(item) => Some(&mut item.attrs),
375        syn::Item::Union(item) => Some(&mut item.attrs),
376        syn::Item::Use(item) => Some(&mut item.attrs),
377        syn::Item::Verbatim(_) => None,
378        _ => None,
379    }
380}
381fn process_single_file(
382    input_path: &PathBuf,
383    args: &StripArgs,
384    output_path: Option<&PathBuf>,
385    backup_dir: &PathBuf,
386) -> Result<()> {
387    println!("📝 Processing single file: {}", input_path.display());
388    let original_content = fs::read_to_string(input_path)?;
389    if !args.no_backup {
390        create_backup(input_path, backup_dir)?;
391    }
392    let stripped_content = strip_rust(&original_content, args)?;
393    let final_output_path = if let Some(output) = output_path {
394        output.clone()
395    } else {
396        let file_stem = input_path
397            .file_stem()
398            .and_then(|s| s.to_str())
399            .unwrap_or("unknown");
400        let extension = input_path.extension().and_then(|s| s.to_str()).unwrap_or("rs");
401        input_path.with_file_name(format!("{}.stripped.{}", file_stem, extension))
402    };
403    if final_output_path == *input_path && !args.force {
404        println!(
405            "⚠️  Output path is same as input. Use --force to overwrite or specify different output path."
406        );
407        println!("   Suggested: --output {}", final_output_path.display());
408        return Ok(());
409    }
410    fs::write(&final_output_path, stripped_content)?;
411    if final_output_path != *input_path {
412        println!("✅ Stripped code written to: {}", final_output_path.display());
413    } else {
414        println!("✅ File overwritten: {}", input_path.display());
415    }
416    let original_lines = original_content.lines().count();
417    let stripped_lines = fs::read_to_string(&final_output_path)?.lines().count();
418    let reduction = if original_lines > 0 && stripped_lines <= original_lines {
419        ((original_lines - stripped_lines) as f64 / original_lines as f64 * 100.0) as i32
420    } else if original_lines > 0 && stripped_lines > original_lines {
421        0
422    } else {
423        0
424    };
425    println!(
426        "📊 Lines: {} → {} ({}% reduction)", original_lines, stripped_lines,
427        reduction
428    );
429    Ok(())
430}
431fn process_directory(
432    dir: &PathBuf,
433    args: &StripArgs,
434    backup_dir: &PathBuf,
435) -> Result<()> {
436    println!(
437        "📁 Processing directory: {} (max depth: {})", dir.display(), args.max_depth
438    );
439    let output_base = if let Some(output) = &args.output {
440        if !output.exists() {
441            fs::create_dir_all(output)?;
442            println!("📁 Created output directory: {}", output.display());
443        }
444        Some(output.clone())
445    } else {
446        None
447    };
448    let mut processed_count = 0;
449    let mut skipped_count = 0;
450    let mut error_count = 0;
451    let walker = WalkDir::new(dir)
452        .max_depth(args.max_depth)
453        .into_iter()
454        .filter_map(|e| e.ok());
455    for entry in walker {
456        let path = entry.path();
457        let extension = path.extension().and_then(|s| s.to_str());
458
459        // SAFETY: Explicitly check for and skip important project files
460        if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
461            if file_name == "Cargo.toml" || file_name == "Cargo.lock"
462                || file_name.ends_with(".json") || file_name.ends_with(".toml") {
463                if args.verbose {
464                    println!("⏭️  Skipping protected file: {}", path.display());
465                }
466                skipped_count += 1;
467                continue;
468            }
469        }
470
471        if extension == Some("rs") {
472            let output_path = if let Some(ref output_base) = output_base {
473                let relative = path.strip_prefix(dir).unwrap_or(path);
474                let output_file = output_base.join(relative);
475                if let Some(parent) = output_file.parent() {
476                    fs::create_dir_all(parent)?;
477                }
478                Some(output_file)
479            } else {
480                None
481            };
482            match process_single_file(
483                &path.to_path_buf(),
484                args,
485                output_path.as_ref(),
486                backup_dir,
487            ) {
488                Ok(_) => {
489                    processed_count += 1;
490                }
491                Err(e) => {
492                    println!("❌ Error processing {}: {}", path.display(), e);
493                    error_count += 1;
494                }
495            }
496        } else if path.is_dir() {
497            continue;
498        } else {
499            if args.verbose {
500                println!("⏭️  Skipping non-Rust file: {}", path.display());
501            }
502            skipped_count += 1;
503        }
504    }
505    println!("📊 Directory processing complete:");
506    println!("   ✅ Files processed: {}", processed_count);
507    if error_count > 0 {
508        println!("   ❌ Errors: {}", error_count);
509    }
510    if skipped_count > 0 {
511        println!("   ⏭️  Files skipped: {}", skipped_count);
512    }
513    Ok(())
514}
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use std::fs;
519    use std::path::PathBuf;
520    use tempfile::TempDir;
521    #[test]
522    fn test_strip_comments() {
523        let source = r#"
524// This is a comment
525fn main() {
526    // Another comment
527    println!("Hello"); // Inline comment
528    /* Block comment */
529}
530"#;
531        let args = StripArgs {
532            input: PathBuf::from("test.rs"),
533            output: None,
534            target: None,
535            remove_blanks: false,
536            recursive: false,
537            force: false,
538            no_backup: false,
539            src: false,
540            max_depth: 10,
541            aggressive: false,
542            minify: false,
543            tease: false,
544            strip_attrs: false,
545            strip_docs: false,
546            inline_uses: false,
547            verbose: false,
548        };
549        let result = strip_rust(source, &args).unwrap();
550        assert!(! result.contains("//"));
551        assert!(! result.contains("/*"));
552        assert!(result.contains("fn main()"));
553        assert!(result.contains("println!"));
554    }
555    #[test]
556    fn test_strip_blank_lines() {
557        let source = r#"fn main() {
558
559    println!("Hello");
560
561}
562"#;
563        let args = StripArgs {
564            input: PathBuf::from("test.rs"),
565            output: None,
566            target: None,
567            remove_blanks: true,
568            recursive: false,
569            force: false,
570            no_backup: false,
571            src: false,
572            max_depth: 10,
573            aggressive: false,
574            minify: false,
575            tease: false,
576            strip_attrs: false,
577            strip_docs: false,
578            inline_uses: false,
579            verbose: false,
580        };
581        let result = strip_rust(source, &args).unwrap();
582        let lines: Vec<&str> = result.lines().collect();
583        let consecutive_empty = lines
584            .windows(2)
585            .any(|window| window[0].trim().is_empty() && window[1].trim().is_empty());
586        assert!(! consecutive_empty);
587    }
588    #[test]
589    fn test_aggressive_stripping() {
590        let source = r#"
591/// This is a doc comment
592#[derive(Debug)]
593fn main() {
594    println!("Hello");
595}
596"#;
597        let args = StripArgs {
598            input: PathBuf::from("test.rs"),
599            output: None,
600            target: None,
601            remove_blanks: false,
602            recursive: false,
603            force: false,
604            no_backup: false,
605            src: false,
606            max_depth: 10,
607            aggressive: true,
608            minify: false,
609            tease: false,
610            strip_attrs: false,
611            strip_docs: false,
612            inline_uses: false,
613            verbose: false,
614        };
615        let result = strip_rust(source, &args).unwrap();
616        assert!(! result.contains("///"));
617        assert!(! result.contains("#[derive(Debug)]"));
618        assert!(result.contains("fn main()"));
619    }
620    #[test]
621    fn test_determine_input_path() {
622        let mut args = StripArgs {
623            input: PathBuf::from("test.rs"),
624            output: None,
625            target: None,
626            remove_blanks: false,
627            recursive: false,
628            force: false,
629            no_backup: false,
630            src: false,
631            max_depth: 10,
632            aggressive: false,
633            minify: false,
634            tease: false,
635            strip_attrs: false,
636            strip_docs: false,
637            inline_uses: false,
638            verbose: false,
639        };
640        assert_eq!(determine_input_path(& args).unwrap(), PathBuf::from("test.rs"));
641        args.src = true;
642        assert_eq!(determine_input_path(& args).unwrap(), PathBuf::from("src"));
643        args.src = false;
644        args.target = Some(PathBuf::from("target/dir"));
645        assert_eq!(determine_input_path(& args).unwrap(), PathBuf::from("target/dir"));
646    }
647    #[test]
648    fn test_backup_creation() {
649        let temp_dir = TempDir::new().unwrap();
650        let test_file = temp_dir.path().join("test.rs");
651        let backup_dir = temp_dir.path().join("backups");
652        fs::create_dir_all(&backup_dir).unwrap();
653        fs::write(&test_file, "fn main() {}").unwrap();
654        let backup_path = create_backup(&test_file, &backup_dir).unwrap();
655        assert!(backup_path.exists());
656        assert!(backup_path.to_string_lossy().contains("test.rs"));
657        assert!(backup_path.to_string_lossy().contains(".backup"));
658    }
659}