Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <plan-id>.
residual leftover occurrences of the old token after applying.
Expand Down Expand Up @@ -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 \
<plan-id>; 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 <plan-id>; 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 <plan-id> -> next: rep apply --plan <plan-id>"
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 <plan-id> -> next: rep apply --plan <plan-id>"
)]
Plan {
/// A literal mapping FROM=TO, e.g. old_name=new_name (repeatable)
#[arg(long = "map", value_name = "FROM=TO")]
map: Vec<String>,

/// 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<String>,

/// Disable content replacement (enabled by default)
#[arg(long = "no-content")]
no_content: bool,
Expand Down
33 changes: 32 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<text::Mapping>> {
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<i32> {
let json = cli.json;
match cli.command {
Expand All @@ -114,16 +143,18 @@ fn dispatch(cli: Cli) -> Result<i32> {

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::<Result<Vec<_>>>()?;
maps.extend(read_map_files(&map_file)?);
planner::run(
PlanOpts {
maps,
Expand Down
39 changes: 38 additions & 1 deletion src/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ pub fn parse_mapping(spec: &str) -> Result<Mapping> {
})
}

/// 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<Vec<Mapping>> {
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.
Expand All @@ -42,7 +64,7 @@ pub fn parse_mapping(spec: &str) -> Result<Mapping> {
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() {
Expand Down Expand Up @@ -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![
Expand Down
154 changes: 154 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down
Loading