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 error;
11pub mod rewriter;
12pub mod rules;
13pub mod walker;
14
15pub use error::{MigrateV2Error, Result};
16
17/// Arguments for the `migrate-manouche-v2` subcommand.
18#[derive(Args, Debug)]
19pub struct MigrateV2Args {
20	/// Root path to migrate. Defaults to the current workspace.
21	#[arg(default_value = ".")]
22	pub path: PathBuf,
23
24	/// Print changes without writing them.
25	#[arg(long)]
26	pub dry_run: bool,
27
28	/// Comma-separated list of rule names to skip (e.g. `--skip use_effect_deps`).
29	#[arg(long, value_delimiter = ',')]
30	pub skip: Vec<String>,
31}
32
33/// Entry point invoked by `main.rs`.
34///
35/// File paths are obtained from `walker::find_rs_files`, which enumerates
36/// entries rooted at the CLI-supplied `--path` directory via `walkdir`. No
37/// remote/HTTP input is involved; this is a developer-run codemod that
38/// rewrites files in the developer's own checkout. Semgrep's Actix
39/// "path-traversal" pattern flags any `std::fs` call whose path argument is
40/// not a string literal, but the only "untrusted" surface here is the
41/// developer's own CLI invocation, which is an intentional capability.
42pub 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			// Skip files we cannot parse (e.g. build scripts with cfg-gated items).
69			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
100/// Compares original and transformed ASTs at the item level, replacing only
101/// the text spans of changed items. Comments, blank lines, and formatting in
102/// unchanged items are preserved from the original source.
103///
104/// Item boundaries are located by searching for each item's prettyprinted text
105/// in the source. `proc_macro2::Span` does not provide real positions outside
106/// of proc-macro context, so a text-based approach is used instead.
107fn 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		// Copy source between the previous item and this one (comments, blank lines).
133		result.push_str(&src[last_pos..start_byte]);
134
135		if formatted_orig == formatted_new {
136			// Item unchanged — keep original source text.
137			result.push_str(&src[start_byte..end_byte]);
138		} else {
139			// Item changed — format the new item via prettyplease.
140			result.push_str(&format_single_item(new_item));
141		}
142
143		last_pos = end_byte;
144	}
145
146	// Copy trailing source after the last item (trailing comments, whitespace).
147	if last_pos < src.len() {
148		result.push_str(&src[last_pos..]);
149	}
150
151	result
152}
153
154/// Format a single `syn::Item` using `prettyplease`.
155///
156/// Wraps the item in a temporary `syn::File` so `prettyplease::unparse` can
157/// format it. Trailing whitespace added by prettyplease is trimmed.
158fn 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
168/// Find an item's byte range in the source by searching for its normalized
169/// token text. Returns `(search_from, search_from)` when the item cannot be
170/// located so the caller can fall back to full-file formatting.
171fn 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
219/// Find the byte offset past the end of an item starting at `src[0]`.
220/// Handles block-delimited items (tracking `{}` nesting) and
221/// semicolon-terminated items. Skips string/char literals, comments,
222/// and nested block constructs so that braces/semicolons inside them
223/// do not corrupt the boundary detection.
224fn 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		// Skip line comments
235		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		// Skip block comments
243		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; // skip "*/"
250			}
251			continue;
252		}
253
254		// Skip raw string literals: r"..." r#"..."# etc.
255		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; // position at the opening quote
269						count
270					} else {
271						i += 1;
272						continue;
273					}
274				};
275				i += 1; // skip opening quote
276				while i < len {
277					if bytes[i] == b'"' {
278						// Check if followed by the right number of hashes
279						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; // skip escaped char
292					} else {
293						i += 1;
294					}
295				}
296				continue;
297			}
298		}
299
300		// Skip regular string literals
301		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; // skip escaped char
310				} else {
311					i += 1;
312				}
313			}
314			continue;
315		}
316
317		// Skip byte literals: b'x'
318		if ch == b'b' && i + 1 < len && bytes[i + 1] == b'\'' {
319			i += 2; // skip "b'"
320			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		// Skip char literals ('x') vs lifetimes ('ident)
335		if ch == b'\'' {
336			i += 1; // skip opening quote
337			if i < len {
338				// Lifetime: ' followed by letter or underscore (e.g. 'a, 'static)
339				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					// Char literal: skip the character (or escape sequence)
345					if bytes[i] == b'\\' && i + 1 < len {
346						i += 2;
347					} else {
348						i += 1;
349					}
350					// Skip closing quote
351					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
381/// Reads a developer-owned source file enumerated by `walker::find_rs_files`.
382///
383/// The path argument is bounded by the CLI-supplied `--path` root; this is a
384/// developer-run codemod, not a network-facing service. We canonicalize the
385/// path before any IO to make the bounds explicit.
386fn read_developer_file(path: &std::path::Path) -> Result<String> {
387	let canonical = path.canonicalize()?;
388	let mut file = std::fs::File::open(canonical)?; // nosemgrep: path-traversal false positive — developer CLI bounded by --path root
389	let mut buf = String::new();
390	std::io::Read::read_to_string(&mut file, &mut buf)?;
391	Ok(buf)
392}
393
394/// Writes the rewritten source back to a developer-owned file. Same scope
395/// note as `read_developer_file`.
396///
397/// Uses a unique temp file in the target's parent directory so that the
398/// final `rename` is atomic (same filesystem). Process ID and a random
399/// suffix prevent collisions under concurrent invocations.
400fn 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")); // nosemgrep: path-traversal false positive — developer CLI bounded by --path root
418	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		// Arrange
438		let directory = tempfile::tempdir().unwrap();
439		let missing_file = directory.path().join("missing.rs");
440
441		// Act
442		let error = read_developer_file(&missing_file).unwrap_err();
443
444		// Assert
445		assert!(matches!(error, MigrateV2Error::Io(_)));
446	}
447
448	#[rstest]
449	fn write_developer_file_rejects_a_path_without_a_parent() {
450		// Arrange
451		let path = Path::new("/");
452
453		// Act
454		let error = write_developer_file(path, "content").unwrap_err();
455
456		// Assert
457		match error {
458			MigrateV2Error::MissingParent(actual) => assert_eq!(actual, PathBuf::from("/")),
459			other => panic!("expected missing parent, got {other:?}"),
460		}
461	}
462
463	/// Verify that when no AST items change, the output is identical to input.
464	#[rstest]
465	fn no_changes_output_identical() {
466		// Arrange
467		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		// Act
472		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
473
474		// Assert
475		assert_eq!(result, src);
476	}
477
478	/// Comments between items are preserved even when other items change.
479	#[rstest]
480	fn comments_between_items_preserved() {
481		// Arrange
482		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		// Simulate a change to the first item only (rename Foo to Foo2).
487		if let syn::Item::Struct(s) = &mut out_ast.items[0] {
488			s.ident = syn::Ident::new("Foo2", s.ident.span());
489		}
490
491		// Act
492		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
493
494		// Assert: changed item reflects new name, comments preserved.
495		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	/// Blank lines between items survive the codemod.
512	#[rstest]
513	fn blank_lines_between_items_preserved() {
514		// Arrange
515		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		// Change the second use statement.
520		if let syn::Item::Use(u) = &mut out_ast.items[1] {
521			// Replace `use std::fs` with `use std::fs::File`.
522			*u = syn::parse_quote!(
523				use std::fs::File;
524			);
525		}
526
527		// Act
528		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
529
530		// Assert: blank lines between items preserved, only changed item replaced.
531		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		// The blank line count should be preserved between unchanged items.
538		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	/// Module-level `//!` doc comments are preserved.
549	#[rstest]
550	fn module_doc_comment_preserved() {
551		// Arrange
552		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		// Change the function.
557		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		// Act
562		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
563
564		// Assert
565		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	/// When only 1 of multiple items changes, the other items stay untouched
574	/// including their original formatting.
575	#[rstest]
576	fn only_changed_item_replaced() {
577		// Arrange
578		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		// Change only the middle item.
583		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		// Act
588		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
589
590		// Assert
591		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		// Verify that only B was changed — A and C are verbatim from source.
604		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		// Arrange
613		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		// Act
621		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
622
623		// Assert
624		assert_eq!(result, prettyplease::unparse(&out_ast));
625	}
626
627	#[rstest]
628	fn missing_item_mapping_falls_back_to_full_unparse() {
629		// Arrange
630		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		// Act
636		let result = apply_changes_preserving_formatting(src, &parsed, &out_ast);
637
638		// Assert
639		assert_eq!(result, prettyplease::unparse(&out_ast));
640	}
641}