Repository: jslay88/qbt_migrate

Issue:
[Feature Request] Make it possible to change only part of the path.
For example, if I am migrating my qBitorrent session but destination Linux install has different username, then the entire path remains the same but only the username changes.

Since I have a lot of sub-directories, changing the full path would not be ideal.

A regex option to only replace certain pattern matching string with another string would be amazing. This way I coud cutomize it to replace username in my case and I am sure other people coud find other uses for the same.

Discussion:

Patch:
diff --git a/.flake8 b/.flake8
index 8ec75f5..43b9644 100644
--- a/.flake8
+++ b/.flake8
@@ -7,4 +7,5 @@ exclude =
     *.egg-info
     .tox
     .git
+    __init__.py
 max-line-length = 120
diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml
new file mode 100644
index 0000000..f7824b0
--- /dev/null
+++ b/.github/workflows/docker-pr.yml
@@ -0,0 +1,37 @@
+# Builds and Pushes Docker image for PRs with label `pr-docker-build` to GHCR only.
+name: Docker Build and Push - PRs
+
+on:
+  pull_request:
+    types: [ opened, reopened, synchronize, labeled ]
+
+jobs:
+  push:
+    name: Build and Push Docker Image
+    if: ${{ contains(github.event.pull_request.labels.*.name, 'pr-docker-build') }}
+    runs-on: ubuntu-latest
+    permissions:
+      packages: write
+      contents: read
+
+    steps:
+      - uses: actions/checkout@v3
+
+      - name: Build Docker Image
+        run: docker build . --file Dockerfile --tag $(echo "${{ github.repository }}" | sed -e 's,.*/\(.*\),\1,') --label "runnumber=${GITHUB_RUN_ID}" --label "gitsha=${{ github.sha }}"
+
+      - name: GitHub Container Registry Login
+        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin
+
+      - name: Push Docker Image to GHCR
+        run: |
+          IMAGE_NAME=$(echo "${{ github.repository }}" | sed -e 's,.*/\(.*\),\1,')
+          IMAGE_ID=ghcr.io/${{ github.repository }}
+          # Change all uppercase to lowercase
+          IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
+          # Lowercase branch name
+          VERSION=$(echo "${{ github.head_ref }}" | tr '[A-Z]' '[a-z]' | sed -e 's,.*/\(.*\),\1,')
+          echo IMAGE_ID=$IMAGE_ID
+          echo VERSION=$VERSION
+          docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
+          docker push $IMAGE_ID:$VERSION
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
index 3537947..6145142 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -14,7 +14,6 @@ jobs:
         toxenv:
           - lint
           - unittest
-          - build
 
     steps:
       - uses: actions/checkout@v3
@@ -24,7 +23,6 @@ jobs:
           filters: |
             python:
               - '**.py'
-              - 'requirements.txt'
               - 'tox.ini'
 
       - name: Set up Python
@@ -67,7 +65,6 @@ jobs:
           filters: |
             python:
               - '**.py'
-              - 'requirements.txt'
               - 'tox.ini'
 
       - name: Setup Python
@@ -93,3 +90,46 @@ jobs:
         if: steps.filter.outputs.python == 'true'
         run: |
           diff-cover coverage/coverage.xml --compare-branch=origin/master --fail-under=100
+
+  build:
+    runs-on: ubuntu-latest
+    name: Build Python Package and Upload to TestPyPi
+
+    steps:
+      - uses: actions/checkout@v3
+      - uses: dorny/paths-filter@v2.10.2
+        id: filter
+        with:
+          filters: |
+            python:
+              - '**.py'
+              - 'pyproject.toml'
+
+      - name: Set up Python
+        uses: actions/setup-python@v2.2.2
+        if: steps.filter.outputs.python == 'true'
+        with:
+          python-version: 3.9
+
+      - name: Install Flit
+        if: steps.filter.outputs.python == 'true'
+        run: |
+          python -m pip install --upgrade pip
+          pip install flit
+
+      - name: Build Python Package and Upload to TestPyPi
+        if: steps.filter.outputs.python == 'true'
+        env:
+          FLIT_USERNAME: __token__
+          FLIT_PASSWORD: ${{ secrets.TESTPYPI_TOKEN }}
+          FLIT_INDEX_URL: https://test.pypi.org/legacy/
+        run: |
+          flit install --symlink --deps production
+          export VERSION_TAG=dev${GITHUB_RUN_ID}; flit publish
+
+      - name: Store Python Package as Artifact
+        uses: actions/upload-artifact@v2.2.4
+        if: steps.filter.outputs.python == 'true'
+        with:
+          name: dist
+          path: dist/
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 07579c7..4461c2e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,7 +11,7 @@ on:
 jobs:
   build:
     runs-on: ubuntu-latest
-    name: Build Python Package
+    name: Build, Upload, and Release
 
     steps:
       - uses: actions/checkout@v3
@@ -21,53 +21,19 @@ jobs:
         with:
           python-version: 3.9
 
-      - name: Install Tox
+      - name: Install Flit
         run: |
           python -m pip install --upgrade pip
-          pip install tox
+          pip install flit
 
-      - name: Build Python Package via Tox
+      - name: Build Python Package and Upload to PyPi
+        env:
+          FLIT_USERNAME: __token__
+          FLIT_PASSWORD: ${{ secrets.PYPI_TOKEN }}
+          FLIT_INDEX_URL: https://upload.pypi.org/legacy/
         run: |
-          tox -e build
-
-      - name: Store Python Package as Artifact
-        uses: actions/upload-artifact@v2.2.4
-        with:
-          name: dist
-          path: dist/
-
-  upload:
-    runs-on: ubuntu-latest
-    name: Upload Package and Create Release
-    permissions:
-      contents: write
-    needs:
-      - build
-
-    steps:
-      - uses: actions/checkout@v3
-        with:
-          fetch-depth: 0
-
-      - name: Setup Python
-        uses: actions/setup-python@v2.2.2
-        with:
-          python-version: 3.9
-
-      - name: Install Twine
-        run: |
-          python -m pip install --upgrade pip
-          pip install twine
-
-      - name: Retreive Python Package Artifact
-        uses: actions/download-artifact@v2.0.10
-        with:
-          name: dist
-          path: dist
-
-      - name: Upload Package to PyPi
-        run: |
-          twine upload -u "__token__" -p "${{ secrets.PYPI_TOKEN }}" dist/*
+          flit install --symlink --deps production
+          flit publish
 
       - name: Create GitHub Release with Python Package
         uses: softprops/action-gh-release@v1
diff --git a/Dockerfile b/Dockerfile
index 8365713..7d27654 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,11 +2,12 @@ FROM python:3-alpine
 ENV BT_BACKUP_PATH=/tmp/BT_backup
 WORKDIR /opt/qbt_migrate
 COPY qbt_migrate qbt_migrate
-COPY requirements.txt requirements.txt
-COPY setup.py setup.py
+COPY pyproject.toml pyproject.toml
 COPY LICENSE.md LICENSE.md
 COPY README.md README.md
-RUN pip install -e .
+RUN pip install flit
+ENV FLIT_ROOT_INSTALL=1
+RUN flit install --symlink --deps production
 RUN printf '#!/bin/ash\nexec qbt_migrate -b $BT_BACKUP_PATH $@' > entrypoint.sh
 RUN chmod +x entrypoint.sh
 ENTRYPOINT ["./entrypoint.sh"]
diff --git a/LICENSE.md b/LICENSE.md
index 3c0c4e8..ae89c14 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -1,7 +1,7 @@
 
 The MIT License (MIT)
 
-Copyright (c) 2020 Justin Slay
+Copyright (c) 2022 Justin Slay
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index c7a414f..dce244a 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ It can also convert slashes when migrating between Windows and Linux/Mac.
 
 Install from PyPi using `pip`, or jump to [Examples](#Examples) for Docker
 
-    pip install qbt-migrate
+    pip install qbt_migrate
 
 Run the script and follow prompts or use CLI arguments with command `qbt_migrate`
 
diff --git a/pyproject.toml b/pyproject.toml
index 5c2fb6a..e7fb432 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,50 @@
+[build-system]
+requires = ["flit_core>=3.2,<4"]
+build-backend = "flit_core.buildapi"
+
+[project]
+name = "qbt_migrate"
+authors = [
+    {name = "Justin Slay", email = "justin.slay@gmail.com"},
+]
+dependencies = [
+    "bencode.py==4.0.0",
+]
+requires-python = ">=3.8"
+readme = "README.md"
+classifiers = [
+    "Development Status :: 5 - Production/Stable",
+    "Intended Audience :: Developers",
+    "Intended Audience :: End Users/Desktop",
+    "Intended Audience :: Information Technology",
+    "License :: OSI Approved :: MIT License",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Topic :: Communications :: File Sharing",
+    "Topic :: Software Development :: Libraries",
+    "Topic :: Software Development :: Libraries :: Python Modules",
+    "Topic :: System :: Recovery Tools",
+    "Typing :: Typed",
+]
+dynamic = [
+    "version",
+    "description",
+]
+
+[project.optional-dependencies]
+test = [
+    "pytest>=2.7.3",
+    "tox"
+]
+
+[project.urls]
+Source = "https://github.com/jslay88/qbt_migrate"
+
+[project.scripts]
+qbt_migrate = "qbt_migrate.cli:main"
+
 [tool.black]
 line-length = 120
 target-version = ['py39']
diff --git a/qbt_migrate/__init__.py b/qbt_migrate/__init__.py
index 54ef8b4..cf2d954 100644
--- a/qbt_migrate/__init__.py
+++ b/qbt_migrate/__init__.py
@@ -1,8 +1,11 @@
-# flake8: noqa F401
+"""qBt Migrate, change the paths of existing torrents in qBittorrent, as well as convert paths to Windows/Linux/Mac"""
 import logging
+import os
 
 from .classes import FastResume, QBTBatchMove
 from .methods import convert_slashes, discover_bt_backup_path
 
 
+__version__ = "2.2.0" + os.getenv("VERSION_TAG", "")
+
 logging.getLogger(__name__).addHandler(logging.NullHandler())
diff --git a/qbt_migrate/classes.py b/qbt_migrate/classes.py
index 731467c..bd4599f 100644
--- a/qbt_migrate/classes.py
+++ b/qbt_migrate/classes.py
@@ -1,5 +1,6 @@
 import logging
 import os
+import re
 from datetime import datetime
 from threading import Thread
 from typing import Optional
@@ -28,6 +29,7 @@ class QBTBatchMove(object):
         self,
         existing_path: str,
         new_path: str,
+        regex_path: bool = False,
         target_os: Optional[str] = None,
         create_backup: bool = True,
         skip_bad_files: bool = False,
@@ -38,6 +40,8 @@ class QBTBatchMove(object):
         :type existing_path: str
         :param new_path: New Path to replace with
         :type new_path: str
+        :param regex_path: Existing and New Paths are regex patterns with capture groups
+        :type regex_path: bool
         :param target_os: If targeting a different OS than the source. Must be Windows, Linux, or Mac.
         :type target_os: str
         :param create_backup: Create a backup archive of the BT_backup directory?
@@ -52,19 +56,27 @@ class QBTBatchMove(object):
             backup_folder(self.bt_backup_path, os.path.join(self.bt_backup_path, backup_filename))
 
         self.logger.info(f"Searching for .fastresume files with path {existing_path} ...")
-        for fast_resume in self.discover_relevant_fast_resume(self.bt_backup_path, existing_path, not skip_bad_files):
+        for fast_resume in self.discover_relevant_fast_resume(
+            self.bt_backup_path, existing_path, regex_path, not skip_bad_files
+        ):
             # Fire and forget
             self.discovered_files.add(fast_resume)
-            Thread(target=fast_resume.replace_paths, args=[existing_path, new_path, target_os, True, False]).start()
+            Thread(
+                target=fast_resume.replace_paths, args=[existing_path, new_path, regex_path, target_os, True, False]
+            ).start()
 
     @classmethod
-    def discover_relevant_fast_resume(cls, bt_backup_path: str, existing_path: str, raise_on_error: bool = True):
+    def discover_relevant_fast_resume(
+        cls, bt_backup_path: str, existing_path: str, regex_path: bool = False, raise_on_error: bool = True
+    ):
         """
         Find .fastresume files that contain the existing path.
         :param bt_backup_path: Path to BT_backup folder
         :type bt_backup_path: str
         :param existing_path: The existing path to look for
         :type existing_path: str
+        :param regex_path: Existing Path is a regex pattern with capture groups
+        :type: bool
         :param raise_on_error: Raise if error parsing .fastresume files
         :type raise_on_error: bool
         :return: List of FastResume Objects
@@ -82,6 +94,10 @@ class QBTBatchMove(object):
                     continue
                 if existing_path in fast_resume.save_path or existing_path in fast_resume.qbt_save_path:
                     yield fast_resume
+                elif regex_path and (
+                    re.match(existing_path, fast_resume.save_path) or re.match(existing_path, fast_resume.qbt_save_path)
+                ):
+                    yield fast_resume
         return
 
     @classmethod
@@ -94,13 +110,14 @@ class QBTBatchMove(object):
         fast_resume: "FastResume",
         existing_path: str,
         new_path: str,
+        regex_path: bool = False,
         target_os: Optional[str] = None,
         save_file: bool = True,
         create_backup: bool = True,
     ):
         if not isinstance(fast_resume, FastResume):
             raise TypeError("Not a FastResume object, cannot replace paths!")
-        fast_resume.replace_paths(existing_path, new_path, target_os, save_file, create_backup)
+        fast_resume.replace_paths(existing_path, new_path, regex_path, target_os, save_file, create_backup)
 
 
 class FastResume(object):
@@ -188,17 +205,29 @@ class FastResume(object):
         self,
         existing_path: str,
         new_path: str,
+        regex_path: bool = False,
         target_os: Optional[str] = None,
         save_file: bool = True,
         create_backup: bool = True,
     ):
         self.logger.info(f"Replacing Paths in FastResume {self.file_path}...")
-        new_save_path = self.save_path.replace(existing_path, new_path) if self.save_path is not None else None
-        new_qbt_save_path = (
-            self.qbt_save_path.replace(existing_path, new_path) if self.qbt_save_path is not None else None
-        )
-        if self.mapped_files:
-            self._data["mapped_files"] = [path.replace(existing_path, new_path) for path in self.mapped_files]
+        if regex_path:
+            new_save_path = None
+            new_qbt_save_path = None
+            pattern = re.compile(existing_path)
+            if self.save_path:
+                new_save_path = pattern.sub(new_path, self.save_path)
+            if self.qbt_save_path:
+                new_qbt_save_path = pattern.sub(new_path, self.qbt_save_path)
+            if self.mapped_files:
+                self._data["mapped_files"] = [pattern.sub(new_path, path) for path in self.mapped_files]
+        else:
+            new_save_path = self.save_path.replace(existing_path, new_path) if self.save_path is not None else None
+            new_qbt_save_path = (
+                self.qbt_save_path.replace(existing_path, new_path) if self.qbt_save_path is not None else None
+            )
+            if self.mapped_files:
+                self._data["mapped_files"] = [path.replace(existing_path, new_path) for path in self.mapped_files]
         self.logger.debug(
             f"Existing Save Path: {existing_path}, New Save Path: {new_path}, " f"Replaced Save Path: {new_save_path}"
         )
diff --git a/qbt_migrate/cli.py b/qbt_migrate/cli.py
index cbe418a..39b3bd1 100644
--- a/qbt_migrate/cli.py
+++ b/qbt_migrate/cli.py
@@ -1,5 +1,6 @@
 import argparse
 import logging
+import sys
 
 from . import QBTBatchMove, discover_bt_backup_path
 
@@ -7,10 +8,17 @@ from . import QBTBatchMove, discover_bt_backup_path
 logger = logging.getLogger(__name__)
 
 
-def parse_args():
+def parse_args(args=None):
     parser = argparse.ArgumentParser()
     parser.add_argument("-e", "--existing-path", help="Existing root of path to look for.")
     parser.add_argument("-n", "--new-path", help="New root path to replace existing root path with.")
+    parser.add_argument(
+        "-r",
+        "--regex",
+        help="Existing and New paths are regex patterns with capture groups.",
+        action="store_true",
+        default=None,
+    )
     parser.add_argument(
         "-t",
         "--target-os",
@@ -34,11 +42,11 @@ def parse_args():
         "-l", "--log-level", help="Log Level, Default is INFO.", choices=["DEBUG", "INFO"], default="INFO"
     )
 
-    return parser.parse_args()
+    return parser.parse_args(args)
 
 
 def main():
-    args = parse_args()
+    args = parse_args(sys.argv[1:])
     logging.basicConfig()
     logger.setLevel(args.log_level)
     logging.getLogger("qbt_migrate").setLevel(args.log_level)
@@ -54,11 +62,32 @@ def main():
         args.existing_path = input("Existing Path: ")
     if args.new_path is None:
         args.new_path = input("New Path: ")
+
+    # Get Valid Regex Input
+    if args.regex is None:
+        while (answer := input("Regex Paths with Capture Groups [y/N]: ").lower().strip()) not in (
+            "y",
+            "yes",
+            "n",
+            "no",
+            "",
+        ):
+            print("Please answer y, n, yes, or no")
+        args.regex = answer.lower().strip() in ["y", "yes"]
+
+    # Get Valid Target OS Input
     if args.target_os is None:
-        args.target_os = input("Target OS (Windows, Linux, Mac, Blank for auto-detect): ")
-    if args.target_os.strip() and args.target_os.strip().lower() not in ("windows", "linux", "mac"):
-        raise ValueError(f"Target OS is not valid. Must be Windows, Linux, or Mac. Received:{args.target_os}")
-    elif not args.target_os.strip():
+        while (answer := input("Target OS (Windows, Linux, Mac, Blank for auto-detect): ").lower().strip()) not in (
+            "windows",
+            "linux",
+            "mac",
+            "",
+        ):
+            print("Please answer Windows, Linux, or Mac")
+        args.target_os = answer.lower().strip()
+
+    # Handle Target OS Auto-Detect if not specified
+    if not args.target_os.strip():
         if "/" in args.existing_path and "\\" in args.new_path:
             logger.info("Auto detected target OS change. Will convert slashes to Windows.")
             args.target_os = "windows"
@@ -67,12 +96,13 @@ def main():
             args.target_os = "linux"
         else:
             args.target_os = None
+
     logger.debug(
         f"Existing Path: {args.existing_path}, New Path: {args.new_path}, "
         f"Target OS: {args.target_os}, Skip Bad Files: {args.skip_bad_files}"
     )
-    qbm.run(args.existing_path, args.new_path, args.target_os, True, args.skip_bad_files)
+    qbm.run(args.existing_path, args.new_path, args.regex, args.target_os, True, args.skip_bad_files)
 
 
 if __name__ == "__main__":
-    main()
+    main()  # pragma: no cover
diff --git a/qbt_migrate/methods.py b/qbt_migrate/methods.py
index 54e3092..8beb4fa 100644
--- a/qbt_migrate/methods.py
+++ b/qbt_migrate/methods.py
@@ -45,4 +45,4 @@ def discover_bt_backup_path():
         return "/config/qBittorrent/BT_backup"
 
     logger.debug("Linux/Mac System")
-    return os.path.join(os.getenv("HOME"), ".local/share/data/qBittorrent/BT_backup")
+    return os.path.join(os.getenv("HOME", "/home"), ".local/share/data/qBittorrent/BT_backup")
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index a439528..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,1 +0,0 @@
-bencode.py==4.0.0
diff --git a/setup.py b/setup.py
deleted file mode 100644
index bdecff1..0000000
--- a/setup.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from setuptools import find_packages, setup
-
-
-with open("README.md", "r") as f:
-    long_description = f.read()
-
-
-with open("requirements.txt", "r") as f:
-    dependencies = f.read().splitlines()
-
-
-setup(
-    name="qbt_migrate",
-    version="2.1.6",
-    packages=find_packages(),
-    install_requires=dependencies,
-    description="Migrate qBittorrent FastResume files.",
-    long_description=long_description,
-    long_description_content_type="text/markdown",
-    author="jslay88",
-    url="https://github.com/jslay88/qbt_migrate",
-    entry_points={"console_scripts": ["qbt_migrate = qbt_migrate.cli:main"]},
-    python_requires=">=3.6.4",
-)
diff --git a/tox.ini b/tox.ini
index ed23a3b..77cb641 100644
--- a/tox.ini
+++ b/tox.ini
@@ -28,12 +28,10 @@ basepython=python3.9
 deps=
     lint:       {[lint-config]deps}
     unittest:   {[unittest-config]deps}
-    build:      {[build-config]deps}
 
 commands=
     lint:               {[lint-config]commands}
     unittest:           {[unittest-config]commands}
-    build:              {[build-config]commands}
 
 
 [lint-config]
@@ -62,7 +60,7 @@ commands=
 #
 # To run unit tests.
 deps=
-    -r{toxinidir}/requirements.txt
+    bencode.py==4.0.0
     pytest
     coverage
 commands=
@@ -71,14 +69,6 @@ commands=
     coverage xml
 
 
-[build-config]
-deps=
-  setuptools
-  wheel
-commands=
-  python setup.py sdist bdist_wheel
-
-
 [testenv:lint]
 # Invoke with: tox -e lint
 recreate=False
@@ -101,7 +91,7 @@ commands={[unittest-config]commands}
 # branch. This will fail if the coverage of changes is below 100%. This env
 # requires a coverage file and should only be run after unittest
 deps=
-    -r{toxinidir}/requirements.txt
+    bencode.py==4.0.0
     pytest
     coverage
     diff-cover


Test patch:
diff --git a/tests/test_classes.py b/tests/test_classes.py
index 393b1cf..89854ce 100644
--- a/tests/test_classes.py
+++ b/tests/test_classes.py
@@ -45,16 +45,19 @@ def test_qbt_batch_move_init(linux_env, bt_backup_path, expected_bt_backup_path)
 
 
 def test_qbt_batch_move_discover_relevant_fast_resume(temp_dir):
-    fast_resume_files = list(QBTBatchMove.discover_relevant_fast_resume(temp_dir, "", True))
+    fast_resume_files = list(QBTBatchMove.discover_relevant_fast_resume(temp_dir, "", False, True))
     assert fast_resume_files == []
 
     for file in glob.glob("./tests/test_files/good*.fastresume"):
         file = Path(file)
         print(f"Copying {file} to {temp_dir / file.name}")
         shutil.copy(file, temp_dir / file.name)
-    fast_resume_files = list(QBTBatchMove.discover_relevant_fast_resume(temp_dir, "", True))
+    fast_resume_files = list(QBTBatchMove.discover_relevant_fast_resume(temp_dir, "", False, True))
     assert len(fast_resume_files) == 2
-    fast_resume_files = list(QBTBatchMove.discover_relevant_fast_resume(temp_dir, "/some/test", True))
+    fast_resume_files = list(QBTBatchMove.discover_relevant_fast_resume(temp_dir, "/some/test", False, True))
+    assert len(fast_resume_files) == 1
+    assert fast_resume_files[0].save_path.startswith("/some/test") is True
+    fast_resume_files = list(QBTBatchMove.discover_relevant_fast_resume(temp_dir, r"/some/(\w+)/.*$", True, True))
     assert len(fast_resume_files) == 1
     assert fast_resume_files[0].save_path.startswith("/some/test") is True
 
@@ -63,9 +66,9 @@ def test_qbt_batch_move_discover_relevant_fast_resume(temp_dir):
         print(f"Copying {file} to {temp_dir / file.name}")
         shutil.copy(file, temp_dir / file.name)
     with pytest.raises(BencodeDecodeError):
-        list(QBTBatchMove.discover_relevant_fast_resume(temp_dir, "/some/test", True))
+        list(QBTBatchMove.discover_relevant_fast_resume(temp_dir, "/some/test", False, True))
 
-    fast_resume_files = list(QBTBatchMove.discover_relevant_fast_resume(temp_dir, "/some/test", False))
+    fast_resume_files = list(QBTBatchMove.discover_relevant_fast_resume(temp_dir, "/some/test", False, False))
     assert len(fast_resume_files) == 1
 
 
@@ -300,3 +303,13 @@ def test_fastresume_replace_paths(monkeypatch):
     assert len(fast_resume.mapped_files) > 0
     for mapped_file in fast_resume.mapped_files:
         assert mapped_file.startswith("/a/new/test/path")
+
+
+def test_fastresume_replace_paths_regex(monkeypatch):
+    fast_resume = FastResume("./tests/test_files/good.fastresume")
+    fast_resume.replace_paths(r"/some/(\w+)/.*$", r"/\1/regex", True, save_file=False, create_backup=False)
+    assert fast_resume.save_path == "/test/regex"
+    assert fast_resume.qbt_save_path == "/test/regex"
+    assert len(fast_resume.mapped_files) > 0
+    for mapped_file in fast_resume.mapped_files:
+        assert mapped_file.startswith("/test/regex")
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..7a99b6b
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,184 @@
+import pytest
+
+from qbt_migrate.cli import main, parse_args
+
+
+class MockQBTBatchMove:
+    instance = None
+    run_call = None
+
+    def __init__(self, bt_backup_path: str = None):
+        self.bt_backup_path = bt_backup_path
+        self.add_instance(self)
+
+    @classmethod
+    def run(cls, *args, **kwargs):
+        cls.run_call = (args, kwargs)
+
+    @classmethod
+    def add_instance(cls, instance):
+        cls.instance = instance
+
+
+class MockUserInput:
+    def __init__(self, items):
+        self.index = 0
+        self.items = items
+
+    def next(self):
+        if self.index < len(self.items):
+            cur, self.index = self.items[self.index], self.index + 1
+            print(f"Returning {cur}")
+            return cur
+
+
+def test_parse_args_defaults():
+    # Check defaults
+    args = parse_args([])
+    assert all(
+        hasattr(args, key)
+        for key in ["existing_path", "new_path", "regex", "target_os", "bt_backup_path", "skip_bad_files", "log_level"]
+    )
+    assert args.existing_path is None
+    assert args.new_path is None
+    assert args.regex is None
+    assert args.target_os is None
+    assert args.bt_backup_path is None
+    assert args.skip_bad_files is False
+    assert args.log_level == "INFO"
+
+
+def test_parse_args_shorthand():
+    # Check shorthand values
+    args = parse_args(
+        ["-e", "existing-path", "-n", "new-path", "-r", "-t", "Linux", "-b", "bt-backup-path", "-s", "-l", "DEBUG"]
+    )
+    assert args.existing_path == "existing-path"
+    assert args.new_path == "new-path"
+    assert args.regex is True
+    assert args.target_os == "Linux"
+    assert args.bt_backup_path == "bt-backup-path"
+    assert args.skip_bad_files is True
+    assert args.log_level == "DEBUG"
+
+
+def test_parse_args_longhand():
+    # Check valid values
+    args = parse_args(
+        [
+            "--existing-path",
+            "existing-path",
+            "--new-path",
+            "new-path",
+            "--regex",
+            "--target-os",
+            "Linux",
+            "--bt-backup-path",
+            "bt-backup-path",
+            "--skip-bad-files",
+            "--log-level",
+            "DEBUG",
+        ]
+    )
+    assert args.existing_path == "existing-path"
+    assert args.new_path == "new-path"
+    assert args.regex is True
+    assert args.target_os == "Linux"
+    assert args.bt_backup_path == "bt-backup-path"
+    assert args.skip_bad_files is True
+    assert args.log_level == "DEBUG"
+
+
+def test_parse_args_target_os():
+    args = parse_args(["-t", "Windows"])
+    assert args.target_os == "Windows"
+    args = parse_args(["-t", "Linux"])
+    assert args.target_os == "Linux"
+    args = parse_args(["-t", "Mac"])
+    assert args.target_os == "Mac"
+
+    with pytest.raises(SystemExit):
+        args = parse_args(["-t", "windows"])
+
+
+def test_main_with_inputs(monkeypatch):
+    mock_user_input = MockUserInput(["bt-backup-path", "existing-path", "new-path", "no", "windows"])
+    monkeypatch.setattr("builtins.input", lambda _: mock_user_input.next())
+    monkeypatch.setattr("qbt_migrate.cli.QBTBatchMove", MockQBTBatchMove)
+    monkeypatch.setattr("sys.argv", ["qbt_migrate"])
+    main()
+    assert MockQBTBatchMove.instance.bt_backup_path == "bt-backup-path"
+    assert len(MockQBTBatchMove.run_call[0]) == 6
+    assert MockQBTBatchMove.run_call[0][0] == "existing-path"
+    assert MockQBTBatchMove.run_call[0][1] == "new-path"
+    assert MockQBTBatchMove.run_call[0][2] is False
+    assert MockQBTBatchMove.run_call[0][3] == "windows"
+    assert MockQBTBatchMove.run_call[0][4] is True
+    assert MockQBTBatchMove.run_call[0][5] is False
+
+
+def test_main_with_args(monkeypatch):
+    monkeypatch.setattr("qbt_migrate.cli.QBTBatchMove", MockQBTBatchMove)
+    monkeypatch.setattr(
+        "sys.argv",
+        [
+            "qbt_migrate",
+            "-b",
+            "different-bt-backup-path",
+            "-e",
+            "different-existing-path",
+            "-n",
+            "different-new-path",
+            "-r",
+            "-t",
+            "Windows",
+            "-s",
+        ],
+    )
+    main()
+    assert MockQBTBatchMove.instance.bt_backup_path == "different-bt-backup-path"
+    assert len(MockQBTBatchMove.run_call[0]) == 6
+    assert MockQBTBatchMove.run_call[0][0] == "different-existing-path"
+    assert MockQBTBatchMove.run_call[0][1] == "different-new-path"
+    assert MockQBTBatchMove.run_call[0][2] is True
+    assert MockQBTBatchMove.run_call[0][3] == "Windows"
+    assert MockQBTBatchMove.run_call[0][4] is True
+    assert MockQBTBatchMove.run_call[0][5] is True
+
+
+def test_main_invalid_input_loops(monkeypatch):
+    monkeypatch.setattr("qbt_migrate.cli.QBTBatchMove", MockQBTBatchMove)
+    monkeypatch.setattr("sys.argv", ["qbt_migrate", "-b", "backup-path", "-e", "e-path", "-n", "n-path", "-s"])
+    mock_user_input = MockUserInput(["not-valid", "yes", "not-valid", "windows"])
+    monkeypatch.setattr("builtins.input", lambda _: mock_user_input.next())
+    main()
+    assert MockQBTBatchMove.instance.bt_backup_path == "backup-path"
+    assert len(MockQBTBatchMove.run_call[0]) == 6
+    assert MockQBTBatchMove.run_call[0][0] == "e-path"
+    assert MockQBTBatchMove.run_call[0][1] == "n-path"
+    assert MockQBTBatchMove.run_call[0][2] is True
+    assert MockQBTBatchMove.run_call[0][3] == "windows"
+    assert MockQBTBatchMove.run_call[0][4] is True
+    assert MockQBTBatchMove.run_call[0][5] is True
+
+
+def test_main_target_os_auto_detect(monkeypatch):
+    monkeypatch.setattr("qbt_migrate.cli.QBTBatchMove", MockQBTBatchMove)
+
+    # Test no OS change
+    mock_user_input = MockUserInput(["bt-backup-path", "existing-path", "new-path", "no", ""])
+    monkeypatch.setattr("builtins.input", lambda _: mock_user_input.next())
+    main()
+    assert MockQBTBatchMove.run_call[0][3] is None
+
+    # Test Windows to Linux
+    mock_user_input = MockUserInput(["bt-backup-path", "C:\\existing\\path", "/new/path", "no", ""])
+    monkeypatch.setattr("builtins.input", lambda _: mock_user_input.next())
+    main()
+    assert MockQBTBatchMove.run_call[0][3] == "linux"
+
+    # Test Linux to Windows
+    mock_user_input = MockUserInput(["bt-backup-path", "/existing/path", "C:\\new\\path", "no", ""])
+    monkeypatch.setattr("builtins.input", lambda _: mock_user_input.next())
+    main()
+    assert MockQBTBatchMove.run_call[0][3] == "windows"