1use std::path::PathBuf;
7
8use clap::Args;
9
10pub mod error;
11pub mod rewriter;
12pub mod rules;
13pub mod walker;
14
15pub use error::{MigrateV2Error, Result};
16
17#[derive(Args, Debug)]
19pub struct MigrateV2Args {
20 #[arg(default_value = ".")]
22 pub path: PathBuf,
23
24 #[arg(long)]
26 pub dry_run: bool,
27
28 #[arg(long, value_delimiter = ',')]
30 pub skip: Vec<String>,
31}
32
33pub fn run(args: MigrateV2Args) -> Result<()> {
43 let all_rules = rules::all();
44 let known_rule_names: std::collections::BTreeSet<&'static str> =
45 all_rules.iter().map(|r| r.name()).collect();
46 let mut unknown: Vec<&str> = args
47 .skip
48 .iter()
49 .map(String::as_str)
50 .filter(|name| !known_rule_names.contains(name))
51 .collect();
52 if !unknown.is_empty() {
53 unknown.sort_unstable();
54 return Err(MigrateV2Error::UnknownSkipRules(unknown.join(", ")));
55 }
56 let rules: Vec<_> = all_rules
57 .into_iter()
58 .filter(|r| !args.skip.iter().any(|s| s == r.name()))
59 .collect();
60
61 let files = walker::find_rs_files(&args.path)?;
62 let mut changed = 0_usize;
63
64 for path in files {
65 let src = read_developer_file(&path)?;
66 let parsed: syn::File = match syn::parse_file(&src) {
67 Ok(f) => f,
68 Err(_) => continue,
70 };
71 let mut out_ast = parsed.clone();
72 for r in &rules {
73 out_ast = r.rewrite(out_ast);
74 }
75
76 let out = apply_changes_preserving_formatting(&src, &parsed, &out_ast);
77 if out != src {
78 changed += 1;
79 if args.dry_run {
80 println!("would rewrite: {}", path.display());
81 } else {
82 write_developer_file(&path, &out)?;
83 println!("rewrote: {}", path.display());
84 }
85 }
86 }
87
88 println!(
89 "\nDone. {} file(s) {}.",
90 changed,
91 if args.dry_run {
92 "would change"
93 } else {
94 "changed"
95 }
96 );
97 Ok(())
98}
99
100fn apply_changes_preserving_formatting(
108 src: &str,
109 parsed: &syn::File,
110 out_ast: &syn::File,
111) -> String {
112 if parsed.items.len() != out_ast.items.len() {
113 return prettyplease::unparse(out_ast);
114 }
115
116 let mut result = String::with_capacity(src.len() + 1024);
117 let mut last_pos: usize = 0;
118
119 let item_count = parsed.items.len();
120 for i in 0..item_count {
121 let orig_item = &parsed.items[i];
122 let new_item = &out_ast.items[i];
123
124 let formatted_orig = format_single_item(orig_item);
125 let formatted_new = format_single_item(new_item);
126
127 let (start_byte, end_byte) = find_item_in_source(src, last_pos, &formatted_orig);
128 if start_byte < last_pos || start_byte >= end_byte || end_byte > src.len() {
129 return prettyplease::unparse(out_ast);
130 }
131
132 result.push_str(&src[last_pos..start_byte]);
134
135 if formatted_orig == formatted_new {
136 result.push_str(&src[start_byte..end_byte]);
138 } else {
139 result.push_str(&format_single_item(new_item));
141 }
142
143 last_pos = end_byte;
144 }
145
146 if last_pos < src.len() {
148 result.push_str(&src[last_pos..]);
149 }
150
151 result
152}
153
154fn format_single_item(item: &syn::Item) -> String {
159 let file = syn::File {
160 shebang: None,
161 attrs: vec![],
162 items: vec![item.clone()],
163 };
164 let formatted = prettyplease::unparse(&file);
165 formatted.trim_end().to_string()
166}
167
168fn find_item_in_source(src: &str, search_from: usize, item_tokens: &str) -> (usize, usize) {
172 let anchor = item_tokens
173 .lines()
174 .find(|l| {
175 let trimmed = l.trim();
176 !trimmed.is_empty()
177 && !trimmed.starts_with("//")
178 && !trimmed.starts_with("#[")
179 && !trimmed.starts_with("///")
180 })
181 .unwrap_or("");
182
183 if anchor.is_empty() {
184 return (search_from, search_from);
185 }
186
187 let rest = &src[search_from..];
188 let start = match rest.find(anchor) {
189 Some(pos) => search_from + pos,
190 None => return (search_from, search_from),
191 };
192 let start = find_item_start_with_prefix(src, search_from, start);
193
194 let after_start = &src[start..];
195 let end_offset = find_item_end_offset(after_start);
196 (start, start + end_offset)
197}
198
199fn find_item_start_with_prefix(src: &str, search_from: usize, item_start: usize) -> usize {
200 let mut start = line_start(src, item_start);
201 while start > search_from {
202 let previous_end = start.saturating_sub(1);
203 let previous_start = line_start(src, previous_end);
204 let line = &src[previous_start..start];
205 let trimmed = line.trim();
206 if trimmed.starts_with("#[") || trimmed.starts_with("///") {
207 start = previous_start;
208 } else {
209 break;
210 }
211 }
212 start
213}
214
215fn line_start(src: &str, pos: usize) -> usize {
216 src[..pos].rfind('\n').map_or(0, |idx| idx + 1)
217}
218
219fn find_item_end_offset(src: &str) -> usize {
225 let mut brace_depth: i32 = 0;
226 let mut has_block = false;
227 let bytes = src.as_bytes();
228 let len = bytes.len();
229 let mut i = 0;
230
231 while i < len {
232 let ch = bytes[i];
233
234 if ch == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
236 while i < len && bytes[i] != b'\n' {
237 i += 1;
238 }
239 continue;
240 }
241
242 if ch == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
244 i += 2;
245 while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
246 i += 1;
247 }
248 if i + 1 < len {
249 i += 2; }
251 continue;
252 }
253
254 if ch == b'r' && i + 1 < len {
256 let next = bytes[i + 1];
257 if next == b'"' || next == b'#' {
258 let hash_count = if next == b'"' {
259 0
260 } else {
261 let mut count = 0;
262 let mut j = i + 1;
263 while j < len && bytes[j] == b'#' {
264 count += 1;
265 j += 1;
266 }
267 if j < len && bytes[j] == b'"' {
268 i = j; count
270 } else {
271 i += 1;
272 continue;
273 }
274 };
275 i += 1; while i < len {
277 if bytes[i] == b'"' {
278 let mut h = 0;
280 let mut j = i + 1;
281 while j < len && bytes[j] == b'#' && h < hash_count {
282 h += 1;
283 j += 1;
284 }
285 if h == hash_count {
286 i = j;
287 break;
288 }
289 }
290 if bytes[i] == b'\\' && i + 1 < len {
291 i += 2; } else {
293 i += 1;
294 }
295 }
296 continue;
297 }
298 }
299
300 if ch == b'"' {
302 i += 1;
303 while i < len {
304 if bytes[i] == b'"' {
305 i += 1;
306 break;
307 }
308 if bytes[i] == b'\\' && i + 1 < len {
309 i += 2; } else {
311 i += 1;
312 }
313 }
314 continue;
315 }
316
317 if ch == b'b' && i + 1 < len && bytes[i + 1] == b'\'' {
319 i += 2; while i < len {
321 if bytes[i] == b'\'' {
322 i += 1;
323 break;
324 }
325 if bytes[i] == b'\\' && i + 1 < len {
326 i += 2;
327 } else {
328 i += 1;
329 }
330 }
331 continue;
332 }
333
334 if ch == b'\'' {
336 i += 1; if i < len {
338 if bytes[i].is_ascii_alphabetic() || bytes[i] == b'_' {
340 while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
341 i += 1;
342 }
343 } else {
344 if bytes[i] == b'\\' && i + 1 < len {
346 i += 2;
347 } else {
348 i += 1;
349 }
350 if i < len && bytes[i] == b'\'' {
352 i += 1;
353 }
354 }
355 }
356 continue;
357 }
358
359 match ch {
360 b'{' => {
361 brace_depth += 1;
362 has_block = true;
363 }
364 b'}' => {
365 brace_depth -= 1;
366 if brace_depth == 0 && has_block {
367 return i + 1;
368 }
369 }
370 b';' if brace_depth == 0 => {
371 return i + 1;
372 }
373 _ => {}
374 }
375 i += 1;
376 }
377
378 src.len()
379}
380
381fn read_developer_file(path: &std::path::Path) -> Result<String> {
387 let canonical = path.canonicalize()?;
388 let mut file = std::fs::File::open(canonical)?; let mut buf = String::new();
390 std::io::Read::read_to_string(&mut file, &mut buf)?;
391 Ok(buf)
392}
393
394fn write_developer_file(path: &std::path::Path, content: &str) -> Result<()> {
401 let canonical = path.canonicalize()?;
402 let parent = canonical
403 .parent()
404 .ok_or_else(|| MigrateV2Error::MissingParent(path.to_path_buf()))?;
405 let file_name = canonical
406 .file_name()
407 .and_then(|n| n.to_str())
408 .unwrap_or("rewrite");
409 let random_suffix: u32 = {
410 use std::time::{SystemTime, UNIX_EPOCH};
411 let nanos = SystemTime::now()
412 .duration_since(UNIX_EPOCH)
413 .unwrap_or_default()
414 .subsec_nanos();
415 nanos ^ std::process::id()
416 };
417 let tmp = parent.join(format!(".{file_name}.{random_suffix:x}.tmp")); if let Err(e) = std::fs::write(&tmp, content) {
419 let _ = std::fs::remove_file(&tmp);
420 return Err(e.into());
421 }
422 if let Err(e) = std::fs::rename(&tmp, canonical) {
423 let _ = std::fs::remove_file(&tmp);
424 return Err(e.into());
425 }
426 Ok(())
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use rstest::rstest;
433 use std::path::{Path, PathBuf};
434
435 #[rstest]
436 fn read_developer_file_classifies_missing_file_as_io_error() {
437 let directory = tempfile::tempdir().unwrap();
439 let missing_file = directory.path().join("missing.rs");
440
441 let error = read_developer_file(&missing_file).unwrap_err();
443
444 assert!(matches!(error, MigrateV2Error::Io(_)));
446 }
447
448 #[rstest]
449 fn write_developer_file_rejects_a_path_without_a_parent() {
450 let path = Path::new("/");
452
453 let error = write_developer_file(path, "content").unwrap_err();
455
456 match error {
458 MigrateV2Error::MissingParent(actual) => assert_eq!(actual, PathBuf::from("/")),
459 other => panic!("expected missing parent, got {other:?}"),
460 }
461 }
462
463 #[rstest]
465 fn no_changes_output_identical() {
466 let src = "//! Module doc comment.\n\nuse std::collections::HashMap;\n\n/// A struct.\npub struct Foo {\n x: i32,\n}\n";
468 let parsed: syn::File = syn::parse_file(src).unwrap();
469 let out_ast = parsed.clone();
470
471 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
473
474 assert_eq!(result, src);
476 }
477
478 #[rstest]
480 fn comments_between_items_preserved() {
481 let src = "//! Module doc.\n\n// Comment before struct\npub struct Foo {\n x: i32,\n}\n\n// Comment between items\npub struct Bar {\n y: String,\n}\n";
483 let parsed: syn::File = syn::parse_file(src).unwrap();
484 let mut out_ast = parsed.clone();
485
486 if let syn::Item::Struct(s) = &mut out_ast.items[0] {
488 s.ident = syn::Ident::new("Foo2", s.ident.span());
489 }
490
491 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
493
494 assert!(
496 result.contains("pub struct Foo2"),
497 "changed item not updated"
498 );
499 assert!(result.contains("//! Module doc."), "module doc lost");
500 assert!(
501 result.contains("// Comment before struct"),
502 "comment before struct lost"
503 );
504 assert!(
505 result.contains("// Comment between items"),
506 "inter-item comment lost"
507 );
508 assert!(result.contains("pub struct Bar"), "unchanged item lost");
509 }
510
511 #[rstest]
513 fn blank_lines_between_items_preserved() {
514 let src = "use std::io;\n\n\nuse std::fs;\n\n\n\nuse std::path;\n";
516 let parsed: syn::File = syn::parse_file(src).unwrap();
517 let mut out_ast = parsed.clone();
518
519 if let syn::Item::Use(u) = &mut out_ast.items[1] {
521 *u = syn::parse_quote!(
523 use std::fs::File;
524 );
525 }
526
527 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
529
530 assert!(result.contains("use std::io;"), "first use lost");
532 assert!(
533 result.contains("use std::fs::File;"),
534 "changed use not updated"
535 );
536 assert!(result.contains("use std::path;"), "third use lost");
537 assert!(
539 result.contains("use std::io;\n\n\n"),
540 "blank lines after first use altered"
541 );
542 assert!(
543 result.contains("\n\n\nuse std::path;"),
544 "blank lines before third use altered"
545 );
546 }
547
548 #[rstest]
550 fn module_doc_comment_preserved() {
551 let src = "//! Crate-level documentation.\n//! Second line.\n\npub fn foo() {}\n";
553 let parsed: syn::File = syn::parse_file(src).unwrap();
554 let mut out_ast = parsed.clone();
555
556 if let syn::Item::Fn(f) = &mut out_ast.items[0] {
558 f.sig.ident = syn::Ident::new("bar", f.sig.ident.span());
559 }
560
561 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
563
564 assert!(
566 result.contains("//! Crate-level documentation."),
567 "module doc lost"
568 );
569 assert!(result.contains("//! Second line."), "second doc line lost");
570 assert!(result.contains("pub fn bar"), "renamed function missing");
571 }
572
573 #[rstest]
576 fn only_changed_item_replaced() {
577 let src = "pub const A: i32 = 1;\npub const B: i32 = 2;\npub const C: i32 = 3;\n";
579 let parsed: syn::File = syn::parse_file(src).unwrap();
580 let mut out_ast = parsed.clone();
581
582 if let syn::Item::Const(c) = &mut out_ast.items[1] {
584 c.ident = syn::Ident::new("B_CHANGED", c.ident.span());
585 }
586
587 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
589
590 assert!(
592 result.contains("pub const A: i32 = 1;"),
593 "first item altered"
594 );
595 assert!(
596 result.contains("pub const B_CHANGED"),
597 "changed item not updated"
598 );
599 assert!(
600 result.contains("pub const C: i32 = 3;"),
601 "third item altered"
602 );
603 let a_idx = result.find("pub const A").unwrap();
605 let b_idx = result.find("pub const B_CHANGED").unwrap();
606 let c_idx = result.find("pub const C").unwrap();
607 assert!(a_idx < b_idx && b_idx < c_idx, "item order changed");
608 }
609
610 #[rstest]
611 fn item_count_mismatch_falls_back_to_full_unparse() {
612 let src = "pub fn a() {}\n";
614 let parsed: syn::File = syn::parse_file(src).unwrap();
615 let mut out_ast = parsed.clone();
616 out_ast.items.push(syn::parse_quote!(
617 pub fn b() {}
618 ));
619
620 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
622
623 assert_eq!(result, prettyplease::unparse(&out_ast));
625 }
626
627 #[rstest]
628 fn missing_item_mapping_falls_back_to_full_unparse() {
629 let parsed_src = "pub fn original() {}\n";
631 let src = "pub fn different() {}\n";
632 let parsed: syn::File = syn::parse_file(parsed_src).unwrap();
633 let out_ast = parsed.clone();
634
635 let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
637
638 assert_eq!(result, prettyplease::unparse(&out_ast));
640 }
641}