Repository: Veinar/envcloak

Issue:
Implement `--force` switch to bypass/allow overwirte
Goal is to implement `--force` switch to allow owerwite decrypted / encrypted files using `encrypt` and `decrypt` for single files and directories.

Expectations:

* Introduce `--force` switch to cli commands `encrypt` and `decrypt`.
* Make clear (colored) statement that `--force` was used.
* Overwrite files and directories if already exist.

Hints from issue comments:

Solution patch:
diff --git a/envcloak/cli.py b/envcloak/cli.py
index 33573d5..042b157 100644
--- a/envcloak/cli.py
+++ b/envcloak/cli.py
@@ -1,9 +1,11 @@
 import os
 from pathlib import Path
+import shutil
 import click
+from click import style
 from envcloak.encryptor import encrypt_file, decrypt_file
 from envcloak.generator import generate_key_file, generate_key_from_password_file
-from envcloak.utils import add_to_gitignore
+from envcloak.utils import add_to_gitignore, calculate_required_space
 from envcloak.validation import (
     check_file_exists,
     check_directory_exists,
@@ -53,7 +55,12 @@ def main():
 @click.option(
     "--dry-run", is_flag=True, help="Perform a dry run without making any changes."
 )
-def encrypt(input, directory, output, key_file, dry_run):
+@click.option(
+    "--force",
+    is_flag=True,
+    help="Force overwrite of existing encrypted files or directories.",
+)
+def encrypt(input, directory, output, key_file, dry_run, force):
     """
     Encrypt environment variables from a file or all files in a directory.
     """
@@ -73,8 +80,25 @@ def encrypt(input, directory, output, key_file, dry_run):
             check_directory_not_empty(directory)
         check_file_exists(key_file)
         check_permissions(key_file)
-        check_output_not_exists(output)
-        check_disk_space(output, required_space=1024 * 1024)
+
+        # Handle overwrite with --force
+        if not force:
+            check_output_not_exists(output)
+        else:
+            if os.path.exists(output):
+                click.echo(
+                    style(
+                        f"⚠️  Warning: Overwriting existing file or directory {output} (--force used).",
+                        fg="yellow",
+                    )
+                )
+                if os.path.isdir(output):
+                    shutil.rmtree(output)  # Remove existing directory
+                else:
+                    os.remove(output)  # Remove existing file
+
+        required_space = calculate_required_space(input, directory)
+        check_disk_space(output, required_space)
 
         if dry_run:
             click.echo("Dry-run checks passed successfully.")
@@ -133,7 +157,12 @@ def encrypt(input, directory, output, key_file, dry_run):
 @click.option(
     "--dry-run", is_flag=True, help="Perform a dry run without making any changes."
 )
-def decrypt(input, directory, output, key_file, dry_run):
+@click.option(
+    "--force",
+    is_flag=True,
+    help="Force overwrite of existing decrypted files or directories.",
+)
+def decrypt(input, directory, output, key_file, dry_run, force):
     """
     Decrypt environment variables from a file or all files in a directory.
     """
@@ -153,8 +182,25 @@ def decrypt(input, directory, output, key_file, dry_run):
             check_directory_not_empty(directory)
         check_file_exists(key_file)
         check_permissions(key_file)
-        check_output_not_exists(output)
-        check_disk_space(output, required_space=1024 * 1024)
+
+        # Handle overwrite with --force
+        if not force:
+            check_output_not_exists(output)
+        else:
+            if os.path.exists(output):
+                click.echo(
+                    style(
+                        f"⚠️  Warning: Overwriting existing file or directory {output} (--force used).",
+                        fg="yellow",
+                    )
+                )
+                if os.path.isdir(output):
+                    shutil.rmtree(output)  # Remove existing directory
+                else:
+                    os.remove(output)  # Remove existing file
+
+        required_space = calculate_required_space(input, directory)
+        check_disk_space(output, required_space)
 
         if dry_run:
             click.echo("Dry-run checks passed successfully.")
diff --git a/envcloak/utils.py b/envcloak/utils.py
index fcdbf60..cb9720a 100644
--- a/envcloak/utils.py
+++ b/envcloak/utils.py
@@ -1,3 +1,4 @@
+import os
 from pathlib import Path
 
 
@@ -22,3 +23,28 @@ def add_to_gitignore(directory: str, filename: str):
         with open(gitignore_path, "w", encoding="utf-8") as gitignore_file:
             gitignore_file.write(f"{filename}\n")
         print(f"Created {gitignore_path} and added '{filename}'")
+
+
+def calculate_required_space(input=None, directory=None):
+    """
+    Calculate the required disk space based on the size of the input file or directory.
+
+    :param input: Path to the file to calculate size.
+    :param directory: Path to the directory to calculate total size.
+    :return: Size in bytes.
+    """
+    if input and directory:
+        raise ValueError(
+            "Both `input` and `directory` cannot be specified at the same time."
+        )
+
+    if input:
+        return os.path.getsize(input)
+
+    if directory:
+        total_size = sum(
+            file.stat().st_size for file in Path(directory).rglob("*") if file.is_file()
+        )
+        return total_size
+
+    return 0
diff --git a/examples/cli/README.md b/examples/cli/README.md
index fcdbb4e..b9aeec2 100644
--- a/examples/cli/README.md
+++ b/examples/cli/README.md
@@ -55,6 +55,7 @@ envcloak encrypt --input .env --output .env.enc --key-file mykey.key
 ```
 
 **Description:** Encrypts your `.env` file into `.env.enc`. The original file remains unchanged.
+> ⚠️  Has additional `--force` flag to allow overwriting of encrypted files.
 
 ### Decrypting Variables
 
@@ -63,6 +64,7 @@ envcloak decrypt --input .env.enc --output .env --key-file mykey.key
 ```
 
 **Description:** Decrypts `.env.enc` back to `.env`. Ensure the `key-file` used matches the one from the encryption step.
+> ⚠️  Has additional `--force` flag to allow overwriting of decrypted files.
 
 ### Rotating Keys
 


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 7dedf9d..5fcdf75 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -381,3 +381,251 @@ def test_decrypt_with_mixed_input_and_directory(runner, mock_files):
 
     assert result.exit_code != 0
     assert "You must provide either --input or --directory, not both." in result.output
+
+
+@patch("envcloak.cli.encrypt_file")
+def test_encrypt_with_force(mock_encrypt_file, runner, isolated_mock_files):
+    """
+    Test the `encrypt` CLI command with the `--force` flag.
+    """
+    input_file = isolated_mock_files / "variables.env"
+    existing_encrypted_file = (
+        isolated_mock_files / "variables.temp.enc"
+    )  # Existing file
+    key_file = isolated_mock_files / "mykey.key"
+
+    # Create a mock existing encrypted file
+    existing_encrypted_file.write_text("existing content")
+
+    def mock_encrypt(input_path, output_path, key):
+        assert os.path.exists(input_path), "Input file does not exist"
+        with open(output_path, "w") as f:
+            f.write(json.dumps({"ciphertext": "encrypted_data"}))
+
+    mock_encrypt_file.side_effect = mock_encrypt
+
+    # Invoke with --force
+    result = runner.invoke(
+        main,
+        [
+            "encrypt",
+            "--input",
+            str(input_file),
+            "--output",
+            str(existing_encrypted_file),
+            "--key-file",
+            str(key_file),
+            "--force",
+        ],
+    )
+
+    assert result.exit_code == 0
+    assert "Overwriting existing file" in result.output
+    mock_encrypt_file.assert_called_once_with(
+        str(input_file), str(existing_encrypted_file), key_file.read_bytes()
+    )
+
+    # Ensure the file was overwritten
+    with open(existing_encrypted_file, "r") as f:
+        assert json.load(f)["ciphertext"] == "encrypted_data"
+
+
+@patch("envcloak.cli.decrypt_file")
+def test_decrypt_with_force(mock_decrypt_file, runner, mock_files):
+    """
+    Test the `decrypt` CLI command with the `--force` flag.
+    """
+    _, encrypted_file, decrypted_file, key_file = mock_files
+
+    # Create a mock existing decrypted file
+    decrypted_file.write_text("existing content")
+
+    def mock_decrypt(input_path, output_path, key):
+        assert os.path.exists(input_path), "Encrypted file does not exist"
+        with open(output_path, "w") as f:
+            f.write("DB_USERNAME=example_user\nDB_PASSWORD=example_pass")
+
+    mock_decrypt_file.side_effect = mock_decrypt
+
+    # Invoke with --force
+    result = runner.invoke(
+        main,
+        [
+            "decrypt",
+            "--input",
+            str(encrypted_file),
+            "--output",
+            str(decrypted_file),
+            "--key-file",
+            str(key_file),
+            "--force",
+        ],
+    )
+
+    assert result.exit_code == 0
+    assert "Overwriting existing file" in result.output
+    mock_decrypt_file.assert_called_once_with(
+        str(encrypted_file), str(decrypted_file), key_file.read_bytes()
+    )
+
+    # Ensure the file was overwritten
+    with open(decrypted_file, "r") as f:
+        assert f.read() == "DB_USERNAME=example_user\nDB_PASSWORD=example_pass"
+
+
+def test_encrypt_without_force_conflict(runner, isolated_mock_files):
+    """
+    Test the `encrypt` CLI command without the `--force` flag when a conflict exists.
+    """
+    input_file = isolated_mock_files / "variables.env"
+    existing_encrypted_file = isolated_mock_files / "variables.temp.enc"
+    key_file = isolated_mock_files / "mykey.key"
+
+    # Create a mock existing encrypted file
+    existing_encrypted_file.write_text("existing content")
+
+    # Invoke without --force
+    result = runner.invoke(
+        main,
+        [
+            "encrypt",
+            "--input",
+            str(input_file),
+            "--output",
+            str(existing_encrypted_file),
+            "--key-file",
+            str(key_file),
+        ],
+    )
+
+    assert "already exists" in result.output
+
+
+def test_decrypt_without_force_conflict(runner, mock_files):
+    """
+    Test the `decrypt` CLI command without the `--force` flag when a conflict exists.
+    """
+    _, encrypted_file, decrypted_file, key_file = mock_files
+
+    # Create a mock existing decrypted file
+    decrypted_file.write_text("existing content")
+
+    # Invoke without --force
+    result = runner.invoke(
+        main,
+        [
+            "decrypt",
+            "--input",
+            str(encrypted_file),
+            "--output",
+            str(decrypted_file),
+            "--key-file",
+            str(key_file),
+        ],
+    )
+
+    assert "already exists" in result.output
+
+
+@patch("envcloak.cli.encrypt_file")
+def test_encrypt_with_force_directory(mock_encrypt_file, runner, isolated_mock_files):
+    """
+    Test the `encrypt` CLI command with the `--force` flag for a directory.
+    """
+    directory = isolated_mock_files / "mock_directory"
+    output_directory = isolated_mock_files / "output_directory"
+    key_file = isolated_mock_files / "mykey.key"
+
+    # Create mock files in the directory
+    directory.mkdir()
+    (directory / "file1.env").write_text("content1")
+    (directory / "file2.env").write_text("content2")
+
+    # Create a mock existing output directory
+    output_directory.mkdir()
+    (output_directory / "file1.env.enc").write_text("existing encrypted content")
+
+    def mock_encrypt(input_path, output_path, key):
+        with open(output_path, "w") as f:
+            f.write(json.dumps({"ciphertext": "encrypted_data"}))
+
+    mock_encrypt_file.side_effect = mock_encrypt
+
+    # Invoke with --force
+    result = runner.invoke(
+        main,
+        [
+            "encrypt",
+            "--directory",
+            str(directory),
+            "--output",
+            str(output_directory),
+            "--key-file",
+            str(key_file),
+            "--force",
+        ],
+    )
+
+    assert "Overwriting existing file" in result.output
+    mock_encrypt_file.assert_any_call(
+        str(directory / "file1.env"),
+        str(output_directory / "file1.env.enc"),
+        key_file.read_bytes(),
+    )
+    mock_encrypt_file.assert_any_call(
+        str(directory / "file2.env"),
+        str(output_directory / "file2.env.enc"),
+        key_file.read_bytes(),
+    )
+
+
+@patch("envcloak.cli.decrypt_file")
+def test_decrypt_with_force_directory(mock_decrypt_file, runner, isolated_mock_files):
+    """
+    Test the `decrypt` CLI command with the `--force` flag for a directory.
+    """
+    directory = isolated_mock_files / "mock_directory"
+    output_directory = isolated_mock_files / "output_directory"
+    key_file = isolated_mock_files / "mykey.key"
+
+    # Create mock encrypted files in the directory
+    directory.mkdir()
+    (directory / "file1.env.enc").write_text("encrypted content1")
+    (directory / "file2.env.enc").write_text("encrypted content2")
+
+    # Create a mock existing output directory
+    output_directory.mkdir()
+    (output_directory / "file1.env").write_text("existing decrypted content")
+
+    def mock_decrypt(input_path, output_path, key):
+        with open(output_path, "w") as f:
+            f.write("decrypted content")
+
+    mock_decrypt_file.side_effect = mock_decrypt
+
+    # Invoke with --force
+    result = runner.invoke(
+        main,
+        [
+            "decrypt",
+            "--directory",
+            str(directory),
+            "--output",
+            str(output_directory),
+            "--key-file",
+            str(key_file),
+            "--force",
+        ],
+    )
+
+    assert "Overwriting existing file" in result.output
+    mock_decrypt_file.assert_any_call(
+        str(directory / "file1.env.enc"),
+        str(output_directory / "file1.env"),
+        key_file.read_bytes(),
+    )
+    mock_decrypt_file.assert_any_call(
+        str(directory / "file2.env.enc"),
+        str(output_directory / "file2.env"),
+        key_file.read_bytes(),
+    )