# frozen_string_literal: true require "optparse" require "pathname" require "securerandom" module BulkRename class CLI def initialize(argv) @argv = argv.dup @dry_run = false @force = false @list_file = nil @pattern = nil @start = 1 end def run parse_options! dir = Pathname.new(@argv.shift || ".").expand_path abort "error: #{dir} is not a directory" unless dir.directory? files = dir.children .select { |f| f.file? && !f.basename.to_s.start_with?(".") } .sort abort "nothing to rename in #{dir}" if files.empty? new_names = @pattern ? sequential_names(files) : names_from_list(dir) if files.length != new_names.length abort "error: #{files.length} file(s) in #{dir} but #{new_names.length} " \ "target name(s) - refusing to guess, fix the mismatch first" end duplicates = new_names.tally.select { |_, count| count > 1 }.keys unless duplicates.empty? abort "error: duplicate target name(s) in the rename list: " \ "#{duplicates.join(', ')} - refusing to run" end pairs = files.zip(new_names).reject { |old_path, new_name| old_path.basename.to_s == new_name } if pairs.empty? puts "nothing to do, every file already has its target name" return 0 end if @dry_run pairs.each { |old_path, new_name| puts "#{old_path.basename} -> #{new_name}" } return 0 end perform_staged_rename(dir, pairs) 0 end private def parse_options! OptionParser.new do |opts| opts.banner = "Usage: bulk-rename [options] [DIRECTORY]\n\n" \ "Renames every file in DIRECTORY (default: current directory), either\n" \ "to names read from a list file, one per line in the same sorted order\n" \ "as the files, or sequentially via --pattern.\n\n" opts.on("-l", "--list FILE", "File with one target name per line " \ "(default: fileNames.txt in the parent of DIRECTORY)") { |f| @list_file = f } opts.on("-p", "--pattern PATTERN", "sprintf pattern for sequential renaming instead of a list, " \ "e.g. 'wallpaper-%04d' - original extension is kept automatically") { |p| @pattern = p } opts.on("-s", "--start N", Integer, "Starting number for --pattern (default: 1)") { |n| @start = n } opts.on("-n", "--dry-run", "Show planned renames without doing them") { @dry_run = true } opts.on("-f", "--force", "Overwrite existing files at the target name") { @force = true } opts.on("-h", "--help", "Show this help") do puts opts exit 0 end end.parse!(@argv) end def names_from_list(dir) path = @list_file ? Pathname.new(@list_file).expand_path : (dir.parent / "fileNames.txt") unless path.exist? abort "error: #{path} not found " \ "(pass --list to point at a different file, or --pattern for sequential renaming)" end path.readlines(chomp: true).reject(&:empty?) end def sequential_names(files) files.each_with_index.map do |file, i| format(@pattern, @start + i) + file.extname end end # The clobbering hazard with an in-place bulk rename isn't hypothetical: # if file A is due to become "3.jpg" while file B (not yet processed) # is currently named "3.jpg", renaming A clobbers B outright. Renaming # in sorted order does not save you here - a name from later in the # list can easily match a name earlier in the list. # # Fix: stage every file under a random temp name first. Only once # every original file is safely out of the way under a name nothing # else could possibly collide with, move each staged file to its real # final name. At that point, an existing file at the target path is # unambiguously either --force territory or a genuine conflict with # something outside this batch - never a false collision with a # not-yet-processed file from the same batch. def perform_staged_rename(dir, pairs) staged = [] begin pairs.each do |old_path, new_name| tmp_path = dir / ".bulk_rename_tmp_#{SecureRandom.hex(8)}" File.rename(old_path, tmp_path) staged << [tmp_path, old_path.basename.to_s, new_name] end rescue SystemCallError => e warn "error while staging files: #{e.message}" report_stranded(staged) exit 1 end begin staged.each do |tmp_path, original_name, new_name| final_path = dir / new_name if final_path.exist? && !@force warn "skipping #{original_name.inspect}: target #{new_name.inspect} already exists " \ "(use --force to overwrite) - left staged as #{tmp_path.basename}" next end File.rename(tmp_path, final_path) puts "#{original_name} -> #{new_name}" end rescue SystemCallError => e warn "error while finalizing renames: #{e.message}" remaining = staged.select { |tmp_path, _original_name, _new_name| tmp_path.exist? } report_stranded(remaining) exit 1 end end def report_stranded(staged) return if staged.empty? warn "the following files are safe but stuck under a temp name - " \ "nothing was lost, finish the rename by hand:" staged.each do |tmp_path, original_name, new_name| warn " #{tmp_path.basename} (was #{original_name}, would become #{new_name})" end end end end