From 4f16a77470c27a95554750cdc96d35c83524f0b7 Mon Sep 17 00:00:00 2001 From: zawakin Date: Mon, 6 Jul 2026 09:50:10 +0900 Subject: [PATCH] feat: read mappings from a file with rep plan --map-file Bulk renames (dozens of FROM=TO pairs) previously forced callers to assemble one huge --map argument list by hand. --map-file reads one FROM=TO per line (blank lines and '#' comments skipped, '-' reads stdin) and combines with --map; duplicate FROMs across sources hit the existing validation. Read failures and bad lines are usage errors (exit 10) that name the file and line. Co-Authored-By: Claude Fable 5 --- src/cli.rs | 17 ++++-- src/main.rs | 33 ++++++++++- src/text.rs | 39 ++++++++++++- tests/cli.rs | 154 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+), 7 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 86cb3ed..5741c99 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -22,8 +22,9 @@ EXAMPLE (rename old_name -> new_name across the repo): rep residual old_name # confirm the old name is gone CONCEPTS: - mapping a literal FROM=TO pair, passed with --map. No regex and no - automatic case handling -- map each casing explicitly + mapping a literal FROM=TO pair, passed with --map (or one per line in a + file via --map-file). No regex and no automatic case handling -- + map each casing explicitly (e.g. --map OldName=NewName --map OLDNAME=NEWNAME). plan a previewed, saved set of edits, identified by a . residual leftover occurrences of the old token after applying. @@ -91,16 +92,22 @@ token occurs and in which files, so you can decide what to rename.")] Preview a rename and save it as a plan. This does NOT change any files. You describe the rename as one or more literal mappings with --map FROM=TO \ -(no regex; map each casing explicitly). rep computes every edit and prints a \ -; pass that id to 'rep apply' to perform the change.")] +(no regex; map each casing explicitly), or list many in a file passed with \ +--map-file. rep computes every edit and prints a ; pass that id to \ +'rep apply' to perform the change.")] #[command( - after_help = "Example:\n rep plan --map old_name=new_name --map OldName=NewName\n # prints a -> next: rep apply --plan " + after_help = "Example:\n rep plan --map old_name=new_name --map OldName=NewName\n rep plan --map-file mappings.txt # one FROM=TO per line; '-' reads stdin\n # prints a -> next: rep apply --plan " )] Plan { /// A literal mapping FROM=TO, e.g. old_name=new_name (repeatable) #[arg(long = "map", value_name = "FROM=TO")] map: Vec, + /// Read mappings from a file, one FROM=TO per line; blank lines and + /// '#' comments are skipped; '-' reads stdin (repeatable) + #[arg(long = "map-file", value_name = "PATH")] + map_file: Vec, + /// Disable content replacement (enabled by default) #[arg(long = "no-content")] no_content: bool, diff --git a/src/main.rs b/src/main.rs index 89a5409..2954fff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,6 +92,35 @@ fn suggested_command() -> &'static str { } } +/// Read every `--map-file` argument into mappings, in flag order. +/// +/// `-` (stdin) is allowed at most once so it is unambiguous which mappings +/// came from the pipe. Read failures are usage errors (exit 10), not generic +/// IO errors, because a missing map file is a mistyped argument. +fn read_map_files(paths: &[String]) -> Result> { + if paths.iter().filter(|p| p.as_str() == "-").count() > 1 { + return Err(RepError::InvalidArguments( + "--map-file '-' (stdin) may be given at most once".to_string(), + )); + } + let mut maps = Vec::new(); + for path in paths { + let (source, content) = if path == "-" { + let content = std::io::read_to_string(std::io::stdin()).map_err(|e| { + RepError::InvalidArguments(format!("cannot read --map-file '-' (stdin): {e}")) + })?; + ("stdin".to_string(), content) + } else { + let content = std::fs::read_to_string(path).map_err(|e| { + RepError::InvalidArguments(format!("cannot read --map-file '{path}': {e}")) + })?; + (path.clone(), content) + }; + maps.extend(text::parse_map_file(&source, &content)?); + } + Ok(maps) +} + fn dispatch(cli: Cli) -> Result { let json = cli.json; match cli.command { @@ -114,16 +143,18 @@ fn dispatch(cli: Cli) -> Result { Commands::Plan { map, + map_file, no_content, rename_paths, include, exclude, tracked_only: _, } => { - let maps = map + let mut maps = map .iter() .map(|s| text::parse_mapping(s)) .collect::>>()?; + maps.extend(read_map_files(&map_file)?); planner::run( PlanOpts { maps, diff --git a/src/text.rs b/src/text.rs index 9ecbbf0..3d19655 100644 --- a/src/text.rs +++ b/src/text.rs @@ -34,6 +34,28 @@ pub fn parse_mapping(spec: &str) -> Result { }) } +/// Parse the contents of a `--map-file`: one `FROM=TO` mapping per line. +/// +/// Blank lines and lines starting with `#` are skipped. `source` names the +/// input (a file path, or "stdin") so errors point at the offending line. +pub fn parse_map_file(source: &str, content: &str) -> Result> { + let mut maps = Vec::new(); + for (idx, raw) in content.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mapping = parse_mapping(line).map_err(|_| { + RepError::InvalidArguments(format!( + "invalid mapping in map file '{source}' line {line_no}: '{line}' (expected FROM=TO)", + line_no = idx + 1 + )) + })?; + maps.push(mapping); + } + Ok(maps) +} + /// Validate that a set of mappings can be applied unambiguously. /// /// Fails when mappings are duplicated or interfere with one another (e.g. @@ -42,7 +64,7 @@ pub fn parse_mapping(spec: &str) -> Result { pub fn validate_mappings(maps: &[Mapping]) -> Result<()> { if maps.is_empty() { return Err(RepError::InvalidArguments( - "at least one --map FROM=TO is required".to_string(), + "at least one mapping is required (--map FROM=TO or --map-file PATH)".to_string(), )); } for (i, a) in maps.iter().enumerate() { @@ -160,6 +182,21 @@ mod tests { assert!(parse_mapping("noequals").is_err()); } + #[test] + fn map_file_skips_blanks_and_comments() { + let content = "\n# casing variants\noldname=newname\n\n OldName=NewName \n"; + let maps = parse_map_file("maps.txt", content).unwrap(); + assert_eq!(maps, vec![m("oldname", "newname"), m("OldName", "NewName")]); + } + + #[test] + fn map_file_error_names_source_and_line() { + let err = parse_map_file("maps.txt", "oldname=newname\nnoequals\n").unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("maps.txt"), "message: {msg}"); + assert!(msg.contains("line 2"), "message: {msg}"); + } + #[test] fn case_variants_are_independent_mappings() { let maps = vec![ diff --git a/tests/cli.rs b/tests/cli.rs index e07e067..bb62431 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -41,6 +41,29 @@ impl RunResult { } } +fn rep_with_stdin(dir: &Path, args: &[&str], stdin: &str) -> RunResult { + use std::io::Write; + use std::process::Stdio; + let mut child = Command::new(rep_bin()) + .args(args) + .current_dir(dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("failed to run rep"); + child + .stdin + .as_mut() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + let out = child.wait_with_output().expect("failed to run rep"); + RunResult { + code: out.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + } +} + fn rep(dir: &Path, args: &[&str]) -> RunResult { let out = Command::new(rep_bin()) .args(args) @@ -505,6 +528,137 @@ fn exclude_rep_allowed() { assert_eq!(res.code, 0); } +// --map-file alone drives a full plan/apply; comments and blank lines skipped +#[test] +fn map_file_plans_and_applies() { + let dir = setup_success_example(); + write( + dir.path(), + "maps.txt", + "# casing variants\noldname=newname\n\nOldName=NewName\nOLDNAME=NEWNAME\n", + ); + git(dir.path(), &["add", "maps.txt"]); + git(dir.path(), &["commit", "-q", "-m", "maps"]); + let res = rep( + dir.path(), + &[ + "plan", + "--map-file", + "maps.txt", + "--exclude", + "maps.txt", + "--json", + ], + ); + assert_eq!(res.code, 0, "plan failed: {}", res.stdout); + let plan_id = res.json()["plan_id"].as_str().unwrap().to_string(); + let res = rep(dir.path(), &["apply", "--plan", &plan_id, "--json"]); + assert_eq!(res.code, 0, "apply failed: {}", res.stdout); + let content = read(dir.path(), "src/oldname.ts"); + assert!(content.contains("newname")); + assert!(content.contains("NEWNAME")); +} + +// --map and --map-file combine; plan.json records maps first, file entries after +#[test] +fn map_and_map_file_combine_in_plan_json() { + let dir = setup_success_example(); + write(dir.path(), "maps.txt", "OldName=NewName\nOLDNAME=NEWNAME\n"); + let res = rep( + dir.path(), + &[ + "plan", + "--map", + "oldname=newname", + "--map-file", + "maps.txt", + "--json", + ], + ); + assert_eq!(res.code, 0, "plan failed: {}", res.stdout); + let plan_id = res.json()["plan_id"].as_str().unwrap().to_string(); + let plan: Value = serde_json::from_str(&read( + dir.path(), + &format!(".rep/plans/{plan_id}/plan.json"), + )) + .unwrap(); + let froms: Vec<&str> = plan["mappings"] + .as_array() + .unwrap() + .iter() + .map(|m| m["from"].as_str().unwrap()) + .collect(); + assert_eq!(froms, vec!["oldname", "OldName", "OLDNAME"]); +} + +// an invalid map-file line is a usage error naming the file and line +#[test] +fn map_file_invalid_line_exit_10() { + let dir = setup_success_example(); + write(dir.path(), "maps.txt", "oldname=newname\nnoequals\n"); + let res = rep(dir.path(), &["plan", "--map-file", "maps.txt", "--json"]); + assert_eq!(res.code, 10); + let msg = res.json()["error"]["message"].as_str().unwrap().to_string(); + assert!(msg.contains("maps.txt"), "message: {msg}"); + assert!(msg.contains("line 2"), "message: {msg}"); +} + +// duplicate FROM between --map and --map-file hits the usual validation +#[test] +fn map_file_duplicate_from_exit_10() { + let dir = setup_success_example(); + write(dir.path(), "maps.txt", "oldname=other\n"); + let res = rep( + dir.path(), + &[ + "plan", + "--map", + "oldname=newname", + "--map-file", + "maps.txt", + "--json", + ], + ); + assert_eq!(res.code, 10); + let msg = res.json()["error"]["message"].as_str().unwrap().to_string(); + assert!(msg.contains("duplicate mapping FROM"), "message: {msg}"); +} + +// a missing map file is a usage error (exit 10), not a generic IO failure +#[test] +fn map_file_missing_exit_10() { + let dir = setup_success_example(); + let res = rep(dir.path(), &["plan", "--map-file", "nope.txt", "--json"]); + assert_eq!(res.code, 10); + let msg = res.json()["error"]["message"].as_str().unwrap().to_string(); + assert!(msg.contains("nope.txt"), "message: {msg}"); +} + +// --map-file - reads mappings from stdin +#[test] +fn map_file_stdin() { + let dir = setup_success_example(); + let res = rep_with_stdin( + dir.path(), + &["plan", "--map-file", "-", "--json"], + "oldname=newname\nOldName=NewName\nOLDNAME=NEWNAME\n", + ); + assert_eq!(res.code, 0, "plan failed: {}", res.stdout); + assert_eq!(res.json()["content"]["replacements"].as_i64().unwrap(), 3); +} + +// stdin may back at most one --map-file +#[test] +fn map_file_stdin_twice_exit_10() { + let dir = setup_success_example(); + let res = rep_with_stdin( + dir.path(), + &["plan", "--map-file", "-", "--map-file", "-", "--json"], + "oldname=newname\n", + ); + assert_eq!(res.code, 10); +} + // matched_directories reports the token-bearing directory prefix #[test] fn matched_directory_prefix() {