Skip to main content

reinhardt_admin_cli/
migrate_v2.rs

1//! Manouche v1 → v2 codemod (spec §6.1 + §6.2).
2//!
3//! Invoked via `reinhardt-admin migrate-manouche-v2 [PATH]` or
4//! `cargo make migrate-manouche-v2`.
5
6use std::path::PathBuf;
7
8use clap::Args;
9
10pub mod rewriter;
11pub mod rules;
12pub mod walker;
13
14/// Arguments for the `migrate-manouche-v2` subcommand.
15#[derive(Args, Debug)]
16pub struct MigrateV2Args {
17	/// Root path to migrate. Defaults to the current workspace.
18	#[arg(default_value = ".")]
19	pub path: PathBuf,
20
21	/// Print changes without writing them.
22	#[arg(long)]
23	pub dry_run: bool,
24
25	/// Comma-separated list of rule names to skip (e.g. `--skip use_effect_deps`).
26	#[arg(long, value_delimiter = ',')]
27	pub skip: Vec<String>,
28}
29
30/// Entry point invoked by `main.rs`.
31///
32/// File paths are obtained from `walker::find_rs_files`, which enumerates
33/// entries rooted at the CLI-supplied `--path` directory via `walkdir`. No
34/// remote/HTTP input is involved; this is a developer-run codemod that
35/// rewrites files in the developer's own checkout. Semgrep's Actix
36/// "path-traversal" pattern flags any `std::fs` call whose path argument is
37/// not a string literal, but the only "untrusted" surface here is the
38/// developer's own CLI invocation, which is an intentional capability.
39pub fn run(args: MigrateV2Args) -> anyhow::Result<()> {
40	let all_rules = rules::all();
41	let known_rule_names: std::collections::BTreeSet<&'static str> =
42		all_rules.iter().map(|r| r.name()).collect();
43	let unknown: Vec<&str> = args
44		.skip
45		.iter()
46		.map(String::as_str)
47		.filter(|name| !known_rule_names.contains(name))
48		.collect();
49	if !unknown.is_empty() {
50		anyhow::bail!("unknown --skip rule(s): {}", unknown.join(", "));
51	}
52	let rules: Vec<_> = all_rules
53		.into_iter()
54		.filter(|r| !args.skip.iter().any(|s| s == r.name()))
55		.collect();
56
57	let files = walker::find_rs_files(&args.path)?;
58	let mut changed = 0_usize;
59
60	for path in files {
61		let src = read_developer_file(&path)?;
62		let parsed: syn::File = match syn::parse_file(&src) {
63			Ok(f) => f,
64			// Skip files we cannot parse (e.g. build scripts with cfg-gated items).
65			Err(_) => continue,
66		};
67		let mut out_ast = parsed.clone();
68		for r in &rules {
69			out_ast = r.rewrite(out_ast);
70		}
71
72		let out = apply_changes_preserving_formatting(&src, &parsed, &out_ast);
73		if out != src {
74			changed += 1;
75			if args.dry_run {
76				println!("would rewrite: {}", path.display());
77			} else {
78				write_developer_file(&path, &out)?;
79				println!("rewrote: {}", path.display());
80			}
81		}
82	}
83
84	println!(
85		"\nDone. {} file(s) {}.",
86		changed,
87		if args.dry_run {
88			"would change"
89		} else {
90			"changed"
91		}
92	);
93	Ok(())
94}
95
96/// Compares original and transformed ASTs at the item level, replacing only
97/// the text spans of changed items. Comments, blank lines, and formatting in
98/// unchanged items are preserved from the original source.
99///
100/// Item boundaries are located by searching for each item's prettyprinted text
101/// in the source. `proc_macro2::Span` does not provide real positions outside
102/// of proc-macro context, so a text-based approach is used instead.
103fn apply_changes_preserving_formatting(
104	src: &str,
105	parsed: &syn::File,
106	out_ast: &syn::File,
107) -> String {
108	if parsed.items.len() != out_ast.items.len() {
109		return prettyplease::unparse(out_ast);
110	}
111
112	let mut result = String::with_capacity(src.len() + 1024);
113	let mut last_pos: usize = 0;
114
115	let item_count = parsed.items.len();
116	for i in 0..item_count {
117		let orig_item = &parsed.items[i];
118		let new_item = &out_ast.items[i];
119
120		let formatted_orig = format_single_item(orig_item);
121		let formatted_new = format_single_item(new_item);
122
123		let (start_byte, end_byte) = find_item_in_source(src, last_pos, &formatted_orig);
124		if start_byte < last_pos || start_byte >= end_byte || end_byte > src.len() {
125			return prettyplease::unparse(out_ast);
126		}
127
128		// Copy source between the previous item and this one (comments, blank lines).
129		result.push_str(&src[last_pos..start_byte]);
130
131		if formatted_orig == formatted_new {
132			// Item unchanged — keep original source text.
133			result.push_str(&src[start_byte..end_byte]);
134		} else {
135			// Item changed — format the new item via prettyplease.
136			result.push_str(&format_single_item(new_item));
137		}
138
139		last_pos = end_byte;
140	}
141
142	// Copy trailing source after the last item (trailing comments, whitespace).
143	if last_pos < src.len() {
144		result.push_str(&src[last_pos..]);
145	}
146
147	result
148}
149
150/// Format a single `syn::Item` using `prettyplease`.
151///
152/// Wraps the item in a temporary `syn::File` so `prettyplease::unparse` can
153/// format it. Trailing whitespace added by prettyplease is trimmed.
154fn format_single_item(item: &syn::Item) -> String {
155	let file = syn::File {
156		shebang: None,
157		attrs: vec![],
158		items: vec![item.clone()],
159	};
160	let formatted = prettyplease::unparse(&file);
161	formatted.trim_end().to_string()
162}
163
164/// Find an item's byte range in the source by searching for its normalized
165/// token text. Returns `(search_from, search_from)` when the item cannot be
166/// located so the caller can fall back to full-file formatting.
167fn find_item_in_source(src: &str, search_from: usize, item_tokens: &str) -> (usize, usize) {
168	let anchor = item_tokens
169		.lines()
170		.find(|l| {
171			let trimmed = l.trim();
172			!trimmed.is_empty()
173				&& !trimmed.starts_with("//")
174				&& !trimmed.starts_with("#[")
175				&& !trimmed.starts_with("///")
176		})
177		.unwrap_or("");
178
179	if anchor.is_empty() {
180		return (search_from, search_from);
181	}
182
183	let rest = &src[search_from..];
184	let start = match rest.find(anchor) {
185		Some(pos) => search_from + pos,
186		None => return (search_from, search_from),
187	};
188	let start = find_item_start_with_prefix(src, search_from, start);
189
190	let after_start = &src[start..];
191	let end_offset = find_item_end_offset(after_start);
192	(start, start + end_offset)
193}
194
195fn find_item_start_with_prefix(src: &str, search_from: usize, item_start: usize) -> usize {
196	let mut start = line_start(src, item_start);
197	while start > search_from {
198		let previous_end = start.saturating_sub(1);
199		let previous_start = line_start(src, previous_end);
200		let line = &src[previous_start..start];
201		let trimmed = line.trim();
202		if trimmed.starts_with("#[") || trimmed.starts_with("///") {
203			start = previous_start;
204		} else {
205			break;
206		}
207	}
208	start
209}
210
211fn line_start(src: &str, pos: usize) -> usize {
212	src[..pos].rfind('\n').map_or(0, |idx| idx + 1)
213}
214
215/// Find the byte offset past the end of an item starting at `src[0]`.
216/// Handles block-delimited items (tracking `{}` nesting) and
217/// semicolon-terminated items. Skips string/char literals, comments,
218/// and nested block constructs so that braces/semicolons inside them
219/// do not corrupt the boundary detection.
220fn find_item_end_offset(src: &str) -> usize {
221	let mut brace_depth: i32 = 0;
222	let mut has_block = false;
223	let bytes = src.as_bytes();
224	let len = bytes.len();
225	let mut i = 0;
226
227	while i < len {
228		let ch = bytes[i];
229
230		// Skip line comments
231		if ch == b'/' && i + 1 < len && bytes[i + 1] == b'/' {
232			while i < len && bytes[i] != b'\n' {
233				i += 1;
234			}
235			continue;
236		}
237
238		// Skip block comments
239		if ch == b'/' && i + 1 < len && bytes[i + 1] == b'*' {
240			i += 2;
241			while i + 1 < len && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
242				i += 1;
243			}
244			if i + 1 < len {
245				i += 2; // skip "*/"
246			}
247			continue;
248		}
249
250		// Skip raw string literals: r"..." r#"..."# etc.
251		if ch == b'r' && i + 1 < len {
252			let next = bytes[i + 1];
253			if next == b'"' || next == b'#' {
254				let hash_count = if next == b'"' {
255					0
256				} else {
257					let mut count = 0;
258					let mut j = i + 1;
259					while j < len && bytes[j] == b'#' {
260						count += 1;
261						j += 1;
262					}
263					if j < len && bytes[j] == b'"' {
264						i = j; // position at the opening quote
265						count
266					} else {
267						i += 1;
268						continue;
269					}
270				};
271				i += 1; // skip opening quote
272				while i < len {
273					if bytes[i] == b'"' {
274						// Check if followed by the right number of hashes
275						let mut h = 0;
276						let mut j = i + 1;
277						while j < len && bytes[j] == b'#' && h < hash_count {
278							h += 1;
279							j += 1;
280						}
281						if h == hash_count {
282							i = j;
283							break;
284						}
285					}
286					if bytes[i] == b'\\' && i + 1 < len {
287						i += 2; // skip escaped char
288					} else {
289						i += 1;
290					}
291				}
292				continue;
293			}
294		}
295
296		// Skip regular string literals
297		if ch == b'"' {
298			i += 1;
299			while i < len {
300				if bytes[i] == b'"' {
301					i += 1;
302					break;
303				}
304				if bytes[i] == b'\\' && i + 1 < len {
305					i += 2; // skip escaped char
306				} else {
307					i += 1;
308				}
309			}
310			continue;
311		}
312
313		// Skip byte literals: b'x'
314		if ch == b'b' && i + 1 < len && bytes[i + 1] == b'\'' {
315			i += 2; // skip "b'"
316			while i < len {
317				if bytes[i] == b'\'' {
318					i += 1;
319					break;
320				}
321				if bytes[i] == b'\\' && i + 1 < len {
322					i += 2;
323				} else {
324					i += 1;
325				}
326			}
327			continue;
328		}
329
330		// Skip char literals ('x') vs lifetimes ('ident)
331		if ch == b'\'' {
332			i += 1; // skip opening quote
333			if i < len {
334				// Lifetime: ' followed by letter or underscore (e.g. 'a, 'static)
335				if bytes[i].is_ascii_alphabetic() || bytes[i] == b'_' {
336					while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
337						i += 1;
338					}
339				} else {
340					// Char literal: skip the character (or escape sequence)
341					if bytes[i] == b'\\' && i + 1 < len {
342						i += 2;
343					} else {
344						i += 1;
345					}
346					// Skip closing quote
347					if i < len && bytes[i] == b'\'' {
348						i += 1;
349					}
350				}
351			}
352			continue;
353		}
354
355		match ch {
356			b'{' => {
357				brace_depth += 1;
358				has_block = true;
359			}
360			b'}' => {
361				brace_depth -= 1;
362				if brace_depth == 0 && has_block {
363					return i + 1;
364				}
365			}
366			b';' if brace_depth == 0 => {
367				return i + 1;
368			}
369			_ => {}
370		}
371		i += 1;
372	}
373
374	src.len()
375}
376
377/// Reads a developer-owned source file enumerated by `walker::find_rs_files`.
378///
379/// The path argument is bounded by the CLI-supplied `--path` root; this is a
380/// developer-run codemod, not a network-facing service. We canonicalize the
381/// path before any IO to make the bounds explicit.
382fn read_developer_file(path: &std::path::Path) -> anyhow::Result<String> {
383	let canonical = path.canonicalize()?;
384	let mut file = std::fs::File::open(canonical)?; // nosemgrep: path-traversal false positive — developer CLI bounded by --path root
385	let mut buf = String::new();
386	std::io::Read::read_to_string(&mut file, &mut buf)?;
387	Ok(buf)
388}
389
390/// Writes the rewritten source back to a developer-owned file. Same scope
391/// note as `read_developer_file`.
392///
393/// Uses a unique temp file in the target's parent directory so that the
394/// final `rename` is atomic (same filesystem). Process ID and a random
395/// suffix prevent collisions under concurrent invocations.
396fn write_developer_file(path: &std::path::Path, content: &str) -> anyhow::Result<()> {
397	let canonical = path.canonicalize()?;
398	let parent = canonical
399		.parent()
400		.ok_or_else(|| anyhow::anyhow!("no parent directory for {}", canonical.display()))?;
401	let file_name = canonical
402		.file_name()
403		.and_then(|n| n.to_str())
404		.unwrap_or("rewrite");
405	let random_suffix: u32 = {
406		use std::time::{SystemTime, UNIX_EPOCH};
407		let nanos = SystemTime::now()
408			.duration_since(UNIX_EPOCH)
409			.unwrap_or_default()
410			.subsec_nanos();
411		nanos ^ std::process::id()
412	};
413	let tmp = parent.join(format!(".{file_name}.{random_suffix:x}.tmp")); // nosemgrep: path-traversal false positive — developer CLI bounded by --path root
414	if let Err(e) = std::fs::write(&tmp, content) {
415		let _ = std::fs::remove_file(&tmp);
416		return Err(e.into());
417	}
418	if let Err(e) = std::fs::rename(&tmp, canonical) {
419		let _ = std::fs::remove_file(&tmp);
420		return Err(e.into());
421	}
422	Ok(())
423}
424
425#[cfg(test)]
426mod tests {
427	use super::*;
428	use rstest::rstest;
429
430	/// Verify that when no AST items change, the output is identical to input.
431	#[rstest]
432	fn no_changes_output_identical() {
433		// Arrange
434		let src = "//! Module doc comment.\n\nuse std::collections::HashMap;\n\n/// A struct.\npub struct Foo {\n    x: i32,\n}\n";
435		let parsed: syn::File = syn::parse_file(src).unwrap();
436		let out_ast = parsed.clone();
437
438		// Act
439		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
440
441		// Assert
442		assert_eq!(result, src);
443	}
444
445	/// Comments between items are preserved even when other items change.
446	#[rstest]
447	fn comments_between_items_preserved() {
448		// Arrange
449		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";
450		let parsed: syn::File = syn::parse_file(src).unwrap();
451		let mut out_ast = parsed.clone();
452
453		// Simulate a change to the first item only (rename Foo to Foo2).
454		if let syn::Item::Struct(s) = &mut out_ast.items[0] {
455			s.ident = syn::Ident::new("Foo2", s.ident.span());
456		}
457
458		// Act
459		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
460
461		// Assert: changed item reflects new name, comments preserved.
462		assert!(
463			result.contains("pub struct Foo2"),
464			"changed item not updated"
465		);
466		assert!(result.contains("//! Module doc."), "module doc lost");
467		assert!(
468			result.contains("// Comment before struct"),
469			"comment before struct lost"
470		);
471		assert!(
472			result.contains("// Comment between items"),
473			"inter-item comment lost"
474		);
475		assert!(result.contains("pub struct Bar"), "unchanged item lost");
476	}
477
478	/// Blank lines between items survive the codemod.
479	#[rstest]
480	fn blank_lines_between_items_preserved() {
481		// Arrange
482		let src = "use std::io;\n\n\nuse std::fs;\n\n\n\nuse std::path;\n";
483		let parsed: syn::File = syn::parse_file(src).unwrap();
484		let mut out_ast = parsed.clone();
485
486		// Change the second use statement.
487		if let syn::Item::Use(u) = &mut out_ast.items[1] {
488			// Replace `use std::fs` with `use std::fs::File`.
489			*u = syn::parse_quote!(
490				use std::fs::File;
491			);
492		}
493
494		// Act
495		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
496
497		// Assert: blank lines between items preserved, only changed item replaced.
498		assert!(result.contains("use std::io;"), "first use lost");
499		assert!(
500			result.contains("use std::fs::File;"),
501			"changed use not updated"
502		);
503		assert!(result.contains("use std::path;"), "third use lost");
504		// The blank line count should be preserved between unchanged items.
505		assert!(
506			result.contains("use std::io;\n\n\n"),
507			"blank lines after first use altered"
508		);
509		assert!(
510			result.contains("\n\n\nuse std::path;"),
511			"blank lines before third use altered"
512		);
513	}
514
515	/// Module-level `//!` doc comments are preserved.
516	#[rstest]
517	fn module_doc_comment_preserved() {
518		// Arrange
519		let src = "//! Crate-level documentation.\n//! Second line.\n\npub fn foo() {}\n";
520		let parsed: syn::File = syn::parse_file(src).unwrap();
521		let mut out_ast = parsed.clone();
522
523		// Change the function.
524		if let syn::Item::Fn(f) = &mut out_ast.items[0] {
525			f.sig.ident = syn::Ident::new("bar", f.sig.ident.span());
526		}
527
528		// Act
529		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
530
531		// Assert
532		assert!(
533			result.contains("//! Crate-level documentation."),
534			"module doc lost"
535		);
536		assert!(result.contains("//! Second line."), "second doc line lost");
537		assert!(result.contains("pub fn bar"), "renamed function missing");
538	}
539
540	/// When only 1 of multiple items changes, the other items stay untouched
541	/// including their original formatting.
542	#[rstest]
543	fn only_changed_item_replaced() {
544		// Arrange
545		let src = "pub const A: i32 = 1;\npub const B: i32 = 2;\npub const C: i32 = 3;\n";
546		let parsed: syn::File = syn::parse_file(src).unwrap();
547		let mut out_ast = parsed.clone();
548
549		// Change only the middle item.
550		if let syn::Item::Const(c) = &mut out_ast.items[1] {
551			c.ident = syn::Ident::new("B_CHANGED", c.ident.span());
552		}
553
554		// Act
555		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
556
557		// Assert
558		assert!(
559			result.contains("pub const A: i32 = 1;"),
560			"first item altered"
561		);
562		assert!(
563			result.contains("pub const B_CHANGED"),
564			"changed item not updated"
565		);
566		assert!(
567			result.contains("pub const C: i32 = 3;"),
568			"third item altered"
569		);
570		// Verify that only B was changed — A and C are verbatim from source.
571		let a_idx = result.find("pub const A").unwrap();
572		let b_idx = result.find("pub const B_CHANGED").unwrap();
573		let c_idx = result.find("pub const C").unwrap();
574		assert!(a_idx < b_idx && b_idx < c_idx, "item order changed");
575	}
576
577	#[rstest]
578	fn item_count_mismatch_falls_back_to_full_unparse() {
579		// Arrange
580		let src = "pub fn a() {}\n";
581		let parsed: syn::File = syn::parse_file(src).unwrap();
582		let mut out_ast = parsed.clone();
583		out_ast.items.push(syn::parse_quote!(
584			pub fn b() {}
585		));
586
587		// Act
588		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
589
590		// Assert
591		assert_eq!(result, prettyplease::unparse(&out_ast));
592	}
593
594	#[rstest]
595	fn missing_item_mapping_falls_back_to_full_unparse() {
596		// Arrange
597		let parsed_src = "pub fn original() {}\n";
598		let src = "pub fn different() {}\n";
599		let parsed: syn::File = syn::parse_file(parsed_src).unwrap();
600		let out_ast = parsed.clone();
601
602		// Act
603		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
604
605		// Assert
606		assert_eq!(result, prettyplease::unparse(&out_ast));
607	}
608}