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 if !input_path.is_dir() {
75 validate_rust_file(&input_path)?;
76 }
77
78 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 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}