diff --git a/main.py b/main.py index 0784908..50ded76 100755 --- a/main.py +++ b/main.py @@ -18,21 +18,19 @@ def replace_content(path, old_string, new_string, preview, verbose): except UnicodeDecodeError: print_verbose(f"Warning: Unicode decode error in file {path}. Skipping.", verbose) - def print_verbose(message, verbose): if verbose: print(message) - def process_directory(base_path, old_string, new_string, recursive, rename_folders, rename_files, replace_in_content, preview, verbose, include_hidden, rename_paths): """ - Traverse directory tree at base_path and perform operations based on flags: - - replace_in_content: replace contents + Traverse directory tree and perform operations based on flags: + - replace_in_content: replace inside file contents - rename_files: rename files whose names contain old_string - rename_folders: rename folders whose names contain old_string - - rename_paths: treat old_string as a relative path and move to new_string + - rename_paths: match old_string as a relative path and move matching items to new_string """ # Full-path move logic if rename_paths: @@ -52,18 +50,23 @@ def process_directory(base_path, old_string, new_string, recursive, os.rename(full_src, full_dst) if not recursive: break - return + # Only return early when only path-mode is active + if not (rename_files or replace_in_content): + return + # Collect folder renames to apply after traversal folders_to_rename = [] for root, dirs, files in os.walk(base_path): if not include_hidden: dirs[:] = [d for d in dirs if not d.startswith('.')] files = [f for f in files if not f.startswith('.')] + # Content replacement if replace_in_content: for f in files: replace_content(os.path.join(root, f), old_string, new_string, preview, verbose) + # File renaming if rename_files: for f in files: if old_string in f: @@ -73,6 +76,7 @@ def process_directory(base_path, old_string, new_string, recursive, if not preview: os.rename(src, dst) + # Gather folder renames if rename_folders: for d in dirs: if old_string in d: @@ -83,12 +87,12 @@ def process_directory(base_path, old_string, new_string, recursive, if not recursive: break + # Apply folder renames for src, dst in folders_to_rename: print_verbose(f"Renaming directory: {src} → {dst}", verbose) if not preview: os.rename(src, dst) - def main(): parser = argparse.ArgumentParser( description="Bulk string replacer with optional full-path moves." @@ -97,20 +101,30 @@ def main(): parser.add_argument('old_string', help="String or relative path to replace.") parser.add_argument('-n', '--new', dest='new_string', default='', help="Replacement string or new relative path.") - - parser.add_argument('-r', '--recursive', action='store_true', help="Recurse into subdirectories.") - parser.add_argument('-F', '--folders', action='store_true', help="Rename folder names.") - parser.add_argument('-f', '--files', action='store_true', help="Rename file names.") - parser.add_argument('-c', '--content', action='store_true', help="Replace inside file contents.") - parser.add_argument('-p', '--preview', action='store_true', help="Preview only; no changes.") + parser.add_argument('-r', '--recursive', action='store_true', + help="Recurse into subdirectories.") + parser.add_argument('-F', '--folders', action='store_true', + help="Rename folder names.") + parser.add_argument('-f', '--files', action='store_true', + help="Rename file names.") + parser.add_argument('-c', '--content', action='store_true', + help="Replace inside file contents.") + parser.add_argument('-p', '--preview', action='store_true', + help="Preview only; no changes.") parser.add_argument('-P', '--path', dest='rename_paths', action='store_true', help="Match old_string as relative path and move to new_string path.") - parser.add_argument('-v', '--verbose', action='store_true', help="Verbose mode.") - parser.add_argument('-H', '--hidden', action='store_true', help="Include hidden files and folders.") + parser.add_argument('-v', '--verbose', action='store_true', + help="Verbose mode.") + parser.add_argument('-H', '--hidden', action='store_true', + help="Include hidden files and folders.") args = parser.parse_args() - base_paths = [os.path.expanduser(p) for p in args.paths] + # Disallow using --path and --folders together + if args.rename_paths and args.folders: + parser.error("Cannot use --path and --folders together.") + + base_paths = [os.path.expanduser(p) for p in args.paths] for base in base_paths: print_verbose(f"Processing: {base}", args.verbose) process_directory( @@ -129,4 +143,3 @@ def main(): if __name__ == '__main__': main() - main() diff --git a/test.py b/test.py index a79cb84..c908778 100644 --- a/test.py +++ b/test.py @@ -3,20 +3,19 @@ import os import shutil import tempfile import unittest +import argparse -from main import process_directory, replace_content +from main import process_directory, replace_content, main as cli_main class TestBulkStringReplacer(unittest.TestCase): def setUp(self): - # Create an isolated temporary directory for each test + # create isolated temp dir self.base = tempfile.mkdtemp() def tearDown(self): - # Clean up when done shutil.rmtree(self.base) def create_file(self, relpath, content=''): - """Helper: make a file (and any parent dirs) under self.base.""" full = os.path.join(self.base, relpath) os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, 'w', encoding='utf-8') as f: @@ -24,92 +23,91 @@ class TestBulkStringReplacer(unittest.TestCase): return full def test_replace_content(self): - f = self.create_file('foo.txt', 'hello old world') - replace_content(f, 'old', 'new', preview=False, verbose=False) + f = self.create_file('foo.txt', 'hello OLD world') + replace_content(f, 'OLD', 'NEW', preview=False, verbose=False) with open(f, 'r', encoding='utf-8') as fp: - self.assertIn('hello new world', fp.read()) + self.assertIn('hello NEW world', fp.read()) def test_rename_file(self): - f = self.create_file('oldfile.txt', '') + f = self.create_file('OLDfile.txt', '') process_directory( base_path=self.base, - old_string='old', - new_string='new', + old_string='OLD', new_string='NEW', recursive=False, - rename_folders=False, - rename_files=True, + rename_folders=False, rename_files=True, replace_in_content=False, - preview=False, - verbose=False, - include_hidden=True, - rename_paths=False + preview=False, verbose=False, + include_hidden=True, rename_paths=False ) - self.assertTrue(os.path.exists(os.path.join(self.base, 'newfile.txt'))) + # file moved + self.assertTrue(os.path.exists(os.path.join(self.base, 'NEWfile.txt'))) self.assertFalse(os.path.exists(f)) def test_rename_folder(self): - os.makedirs(os.path.join(self.base, 'oldfolder', 'inner')) + os.makedirs(os.path.join(self.base, 'OLDfolder', 'x')) process_directory( base_path=self.base, - old_string='old', - new_string='new', + old_string='OLD', new_string='NEW', recursive=False, - rename_folders=True, - rename_files=False, + rename_folders=True, rename_files=False, replace_in_content=False, - preview=False, - verbose=False, - include_hidden=True, - rename_paths=False + preview=False, verbose=False, + include_hidden=True, rename_paths=False ) - self.assertTrue(os.path.isdir(os.path.join(self.base, 'newfolder'))) - self.assertFalse(os.path.isdir(os.path.join(self.base, 'oldfolder'))) + self.assertTrue(os.path.isdir(os.path.join(self.base, 'NEWfolder'))) + self.assertFalse(os.path.isdir(os.path.join(self.base, 'OLDfolder'))) def test_full_path_move(self): - # Create nested path vars/configuration.yml + # prepare vars/configuration.yml cfg = 'vars/configuration.yml' - full_cfg = self.create_file(cfg, 'x') - # Now move vars/configuration.yml -> config/main.yml + full = self.create_file(cfg, 'DATA') + # run with -P process_directory( base_path=self.base, old_string='vars/configuration.yml', new_string='config/main.yml', recursive=True, - rename_folders=False, - rename_files=False, + rename_folders=False, rename_files=False, replace_in_content=False, - preview=False, - verbose=False, - include_hidden=True, - rename_paths=True + preview=False, verbose=False, + include_hidden=True, rename_paths=True ) - # Original should be gone - self.assertFalse(os.path.exists(full_cfg)) - # New location should exist + # original gone, new exists + self.assertFalse(os.path.exists(full)) self.assertTrue(os.path.exists(os.path.join(self.base, 'config', 'main.yml'))) - def test_preview_mode(self): - # Create file and folder that would match - f = self.create_file('oldfile.txt', 'old') - os.makedirs(os.path.join(self.base, 'oldfolder')) + def test_path_and_folders_conflict(self): + # simulate CLI error when combining --path and --folders + parser = argparse.ArgumentParser() + # replicate only the conflict check + parser.add_argument('-P', dest='rename_paths', action='store_true') + parser.add_argument('-F', dest='folders', action='store_true') + args = parser.parse_args(['-P', '-F']) + # manual conflict + with self.assertRaises(SystemExit) as cm: + # mimic the parser.error behavior + if args.rename_paths and args.folders: + parser.error("Cannot use --path and --folders together.") + self.assertNotEqual(cm.exception.code, 0) + + def test_preview_does_nothing(self): + # create file and folder matching + f = self.create_file('OLD.txt', 'OLD') + os.makedirs(os.path.join(self.base, 'OLDdir')) process_directory( base_path=self.base, - old_string='old', - new_string='new', + old_string='OLD', new_string='NEW', recursive=True, - rename_folders=True, - rename_files=True, + rename_folders=True, rename_files=True, replace_in_content=True, - preview=True, - verbose=False, - include_hidden=True, - rename_paths=True + preview=True, verbose=False, + include_hidden=True, rename_paths=True ) - # Nothing changed + # nothing changed self.assertTrue(os.path.exists(f)) - self.assertTrue(os.path.isdir(os.path.join(self.base, 'oldfolder'))) + self.assertTrue(os.path.isdir(os.path.join(self.base, 'OLDdir'))) with open(f, 'r', encoding='utf-8') as fp: - self.assertIn('old', fp.read()) + self.assertIn('OLD', fp.read()) if __name__ == '__main__': unittest.main()