Repository: Veinar/envcloak

Issue:
Create `--dry-run` arg for CLI commands
Goal is to implement `--dry-run` arg for CLI commands in order to perform checks whether:

* passed salt is valid
* file to encrypt exist
* directory to encrypt exist
* file specified `--output` does not exist already.
* directory specified `--output` exist and contain files that will be overwritten.
etc.

Try to spearate `dry-run` logic out of `cli.py`

Discussion:

Solution patch:
diff --git a/envcloak/cli.py b/envcloak/cli.py
index 25da618..48788cb 100644
--- a/envcloak/cli.py
+++ b/envcloak/cli.py
@@ -4,6 +4,24 @@
 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.validation import (
+    check_file_exists,
+    check_directory_exists,
+    check_directory_not_empty,
+    check_output_not_exists,
+    check_permissions,
+    check_disk_space,
+    validate_salt,
+)
+from envcloak.exceptions import (
+    KeyFileNotFoundException,
+    DirectoryEmptyException,
+    OutputFileExistsException,
+    DiskSpaceException,
+    InvalidSaltException,
+    FileEncryptionException,
+    FileDecryptionException,
+)
 
 
 @click.group()
@@ -34,43 +52,64 @@ def main():
 @click.option(
     "--key-file", "-k", required=True, help="Path to the encryption key file."
 )
-def encrypt(input, directory, output, key_file):
+@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):
     """
     Encrypt environment variables from a file or all files in a directory.
     """
-    if not input and not directory:
-        raise click.UsageError("You must provide either --input or --directory.")
-    if input and directory:
-        raise click.UsageError(
-            "You must provide either --input or --directory, not both."
-        )
-
-    with open(key_file, "rb") as kf:
-        key = kf.read()
-
-    if input:
-        # Encrypt a single file
-        encrypt_file(input, output, key)
-        click.echo(f"File {input} encrypted -> {output} using key {key_file}")
-    elif directory:
-        # Encrypt all files in the directory
-        input_dir = Path(directory)
-        output_dir = Path(output)
-
-        if not input_dir.is_dir():
+    try:
+        if not input and not directory:
+            raise click.UsageError("You must provide either --input or --directory.")
+        if input and directory:
             raise click.UsageError(
-                f"The specified directory does not exist: {directory}"
+                "You must provide either --input or --directory, not both."
             )
-        if not output_dir.exists():
-            output_dir.mkdir(parents=True)
 
-        for file in input_dir.iterdir():
-            if file.is_file():  # Skip directories
-                output_file = output_dir / (file.name + ".enc")
-                encrypt_file(str(file), str(output_file), key)
-                click.echo(
-                    f"File {file} encrypted -> {output_file} using key {key_file}"
-                )
+        if dry_run:
+            # Dry-run validation
+            if input:
+                check_file_exists(input)
+                check_permissions(input)
+            if directory:
+                check_directory_exists(directory)
+                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)
+            click.echo("Dry-run checks passed successfully.")
+            return
+
+        # Actual encryption logic
+        with open(key_file, "rb") as kf:
+            key = kf.read()
+
+        if input:
+            encrypt_file(input, output, key)
+            click.echo(f"File {input} encrypted -> {output} using key {key_file}")
+        elif directory:
+            input_dir = Path(directory)
+            output_dir = Path(output)
+            if not output_dir.exists():
+                output_dir.mkdir(parents=True)
+
+            for file in input_dir.iterdir():
+                if file.is_file():  # Skip directories
+                    output_file = output_dir / (file.name + ".enc")
+                    encrypt_file(str(file), str(output_file), key)
+                    click.echo(
+                        f"File {file} encrypted -> {output_file} using key {key_file}"
+                    )
+    except (
+        KeyFileNotFoundException,
+        DirectoryEmptyException,
+        OutputFileExistsException,
+        DiskSpaceException,
+        FileEncryptionException,
+    ) as e:
+        click.echo(f"Error during encryption: {str(e)}")
 
 
 @click.command()
@@ -95,43 +134,64 @@ def encrypt(input, directory, output, key_file):
 @click.option(
     "--key-file", "-k", required=True, help="Path to the decryption key file."
 )
-def decrypt(input, directory, output, key_file):
+@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):
     """
     Decrypt environment variables from a file or all files in a directory.
     """
-    if not input and not directory:
-        raise click.UsageError("You must provide either --input or --directory.")
-    if input and directory:
-        raise click.UsageError(
-            "You must provide either --input or --directory, not both."
-        )
-
-    with open(key_file, "rb") as kf:
-        key = kf.read()
-
-    if input:
-        # Decrypt a single file
-        decrypt_file(input, output, key)
-        click.echo(f"File {input} decrypted -> {output} using key {key_file}")
-    elif directory:
-        # Decrypt all files in the directory
-        input_dir = Path(directory)
-        output_dir = Path(output)
-
-        if not input_dir.is_dir():
+    try:
+        if not input and not directory:
+            raise click.UsageError("You must provide either --input or --directory.")
+        if input and directory:
             raise click.UsageError(
-                f"The specified directory does not exist: {directory}"
+                "You must provide either --input or --directory, not both."
             )
-        if not output_dir.exists():
-            output_dir.mkdir(parents=True)
 
-        for file in input_dir.iterdir():
-            if file.is_file() and file.suffix == ".enc":  # Only decrypt .enc files
-                output_file = output_dir / file.stem  # Remove .enc from filename
-                decrypt_file(str(file), str(output_file), key)
-                click.echo(
-                    f"File {file} decrypted -> {output_file} using key {key_file}"
-                )
+        if dry_run:
+            # Dry-run validation
+            if input:
+                check_file_exists(input)
+                check_permissions(input)
+            if directory:
+                check_directory_exists(directory)
+                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)
+            click.echo("Dry-run checks passed successfully.")
+            return
+
+        # Actual decryption logic
+        with open(key_file, "rb") as kf:
+            key = kf.read()
+
+        if input:
+            decrypt_file(input, output, key)
+            click.echo(f"File {input} decrypted -> {output} using key {key_file}")
+        elif directory:
+            input_dir = Path(directory)
+            output_dir = Path(output)
+            if not output_dir.exists():
+                output_dir.mkdir(parents=True)
+
+            for file in input_dir.iterdir():
+                if file.is_file() and file.suffix == ".enc":  # Only decrypt .enc files
+                    output_file = output_dir / file.stem  # Remove .enc from filename
+                    decrypt_file(str(file), str(output_file), key)
+                    click.echo(
+                        f"File {file} decrypted -> {output_file} using key {key_file}"
+                    )
+    except (
+        KeyFileNotFoundException,
+        DirectoryEmptyException,
+        OutputFileExistsException,
+        DiskSpaceException,
+        FileDecryptionException,
+    ) as e:
+        click.echo(f"Error during decryption: {str(e)}")
 
 
 @click.command()
@@ -141,15 +201,27 @@ def decrypt(input, directory, output, key_file):
 @click.option(
     "--no-gitignore", is_flag=True, help="Skip adding the key file to .gitignore."
 )
-def generate_key(output, no_gitignore):
+@click.option(
+    "--dry-run", is_flag=True, help="Perform a dry run without making any changes."
+)
+def generate_key(output, no_gitignore, dry_run):
     """
     Generate a new encryption key.
     """
-    output_path = Path(output)
+    try:
+        if dry_run:
+            check_output_not_exists(output)
+            check_disk_space(output, required_space=32)
+            click.echo("Dry-run checks passed successfully.")
+            return
 
-    generate_key_file(output_path)
-    if not no_gitignore:
-        add_to_gitignore(output_path.parent, output_path.name)
+        # Actual key generation logic
+        output_path = Path(output)
+        generate_key_file(output_path)
+        if not no_gitignore:
+            add_to_gitignore(output_path.parent, output_path.name)
+    except (OutputFileExistsException, DiskSpaceException) as e:
+        click.echo(f"Error during key generation: {str(e)}")
 
 
 @click.command()
@@ -165,15 +237,29 @@ def generate_key(output, no_gitignore):
 @click.option(
     "--no-gitignore", is_flag=True, help="Skip adding the key file to .gitignore."
 )
-def generate_key_from_password(password, output, salt, no_gitignore):
+@click.option(
+    "--dry-run", is_flag=True, help="Perform a dry run without making any changes."
+)
+def generate_key_from_password(password, salt, output, no_gitignore, dry_run):
     """
     Derive an encryption key from a password and salt.
     """
-    output_path = Path(output)
+    try:
+        if dry_run:
+            check_output_not_exists(output)
+            check_disk_space(output, required_space=32)
+            if salt:
+                validate_salt(salt)
+            click.echo("Dry-run checks passed successfully.")
+            return
 
-    generate_key_from_password_file(password, output_path, salt)
-    if not no_gitignore:
-        add_to_gitignore(output_path.parent, output_path.name)
+        # Actual key derivation logic
+        output_path = Path(output)
+        generate_key_from_password_file(password, output_path, salt)
+        if not no_gitignore:
+            add_to_gitignore(output_path.parent, output_path.name)
+    except (OutputFileExistsException, DiskSpaceException, InvalidSaltException) as e:
+        click.echo(f"Error during key derivation: {str(e)}")
 
 
 @click.command()
@@ -187,20 +273,45 @@ def generate_key_from_password(password, output, salt, no_gitignore):
     "--new-key-file", "-nk", required=True, help="Path to the new encryption key."
 )
 @click.option("--output", "-o", required=True, help="Path to the re-encrypted file.")
-def rotate_keys(input, old_key_file, new_key_file, output):
+@click.option(
+    "--dry-run", is_flag=True, help="Perform a dry run without making any changes."
+)
+def rotate_keys(input, old_key_file, new_key_file, output, dry_run):
     """
     Rotate encryption keys by re-encrypting a file with a new key.
     """
-    with open(old_key_file, "rb") as okf:
-        old_key = okf.read()
-    with open(new_key_file, "rb") as nkf:
-        new_key = nkf.read()
-    # Decrypt with old key and re-encrypt with new key
-    temp_decrypted = f"{output}.tmp"
-    decrypt_file(input, temp_decrypted, old_key)
-    encrypt_file(temp_decrypted, output, new_key)
-    os.remove(temp_decrypted)  # Clean up temporary file
-    click.echo(f"Keys rotated for {input} -> {output}")
+    try:
+        if dry_run:
+            check_file_exists(input)
+            check_permissions(input)
+            check_file_exists(old_key_file)
+            check_permissions(old_key_file)
+            check_file_exists(new_key_file)
+            check_permissions(new_key_file)
+            check_output_not_exists(output)
+            check_disk_space(output, required_space=1024 * 1024)
+            click.echo("Dry-run checks passed successfully.")
+            return
+
+        # Actual key rotation logic
+        with open(old_key_file, "rb") as okf:
+            old_key = okf.read()
+        with open(new_key_file, "rb") as nkf:
+            new_key = nkf.read()
+
+        temp_decrypted = f"{output}.tmp"
+        decrypt_file(input, temp_decrypted, old_key)
+        encrypt_file(temp_decrypted, output, new_key)
+        os.remove(temp_decrypted)  # Clean up temporary file
+        click.echo(f"Keys rotated for {input} -> {output}")
+    except (
+        KeyFileNotFoundException,
+        OutputFileExistsException,
+        DiskSpaceException,
+        FileDecryptionException,
+        FileEncryptionException,
+    ) as e:
+        click.echo(f"Error during key rotation: {str(e)}")
 
 
 # Add all commands to the main group
diff --git a/envcloak/exceptions.py b/envcloak/exceptions.py
index f99574b..51f663b 100644
--- a/envcloak/exceptions.py
+++ b/envcloak/exceptions.py
@@ -40,9 +40,27 @@ class UnsupportedFileFormatException(EncryptedEnvLoaderException):
     default_message = "Unsupported file format detected."
 
 
-#### Cryptography exceptions
+class DirectoryEmptyException(EncryptedEnvLoaderException):
+    """Raised when an input directory is empty."""
 
+    default_message = "The provided input directory is empty."
 
+
+class OutputFileExistsException(EncryptedEnvLoaderException):
+    """Raised when the output file already exists and may be overwritten."""
+
+    default_message = (
+        "The output file or directory already exists and will be overwritten."
+    )
+
+
+class DiskSpaceException(EncryptedEnvLoaderException):
+    """Raised when there is insufficient disk space."""
+
+    default_message = "Insufficient disk space available for this operation."
+
+
+#### Cryptography Exceptions
 class CryptographyException(Exception):
     """Base exception for cryptographic errors."""
 
diff --git a/envcloak/validation.py b/envcloak/validation.py
new file mode 100644
index 0000000..7d081d2
--- /dev/null
+++ b/envcloak/validation.py
@@ -0,0 +1,89 @@
+import os
+from pathlib import Path
+import shutil
+from envcloak.exceptions import (
+    KeyFileNotFoundException,
+    InvalidSaltException,
+    OutputFileExistsException,
+    DirectoryEmptyException,
+    DiskSpaceException,
+)
+
+
+def validate_salt(salt: str):
+    """Check if the provided salt is a valid hex string of the correct length."""
+    if not salt:
+        return  # Valid if no salt is provided
+    if len(salt) != 32 or not all(c in "0123456789abcdefABCDEF" for c in salt):
+        raise InvalidSaltException(
+            details="Salt must be a 16-byte hex string (32 characters)."
+        )
+
+
+def check_file_exists(file_path: str):
+    """Check if a file exists."""
+    if not Path(file_path).is_file():
+        raise KeyFileNotFoundException(details=f"File not found: {file_path}")
+
+
+def check_directory_exists(directory_path: str):
+    """Check if a directory exists."""
+    if not Path(directory_path).is_dir():
+        raise FileNotFoundError(f"Directory does not exist: {directory_path}")
+
+
+def check_directory_not_empty(directory_path: str):
+    """Check if a directory is not empty."""
+    dir_path = Path(directory_path)
+    if not any(file.is_file() for file in dir_path.iterdir()):
+        raise DirectoryEmptyException(
+            details=f"The directory is empty: {directory_path}"
+        )
+
+
+def check_output_not_exists(output_path: str):
+    """Check if an output file or directory does not already exist."""
+    if Path(output_path).exists():
+        raise OutputFileExistsException(
+            details=f"Output path already exists: {output_path}"
+        )
+
+
+def check_directory_overwrite(directory_path: str):
+    """
+    Check if a directory exists and contains files that may be overwritten.
+    """
+    dir_path = Path(directory_path)
+    if dir_path.is_dir() and any(file.is_file() for file in dir_path.iterdir()):
+        raise OutputFileExistsException(
+            details=f"Directory already exists and contains files: {directory_path}"
+        )
+
+
+def check_permissions(file_path: str, write: bool = False):
+    """Check if a file or directory has read/write permissions."""
+    path = Path(file_path)
+    if write and not os.access(path, os.W_OK):
+        raise PermissionError(f"Write permission denied: {file_path}")
+    if not write and not os.access(path, os.R_OK):
+        raise PermissionError(f"Read permission denied: {file_path}")
+
+
+def check_disk_space(output_path: str, required_space: int):
+    """Check if there is enough disk space at the output path."""
+    output_dir = Path(output_path).parent
+    if not output_dir.exists():
+        return  # Assume enough space if directory doesn't exist yet
+    _, _, free = shutil.disk_usage(output_dir)
+    if free < required_space:
+        raise DiskSpaceException(
+            details=f"Available: {free} bytes, Required: {required_space} bytes."
+        )
+
+
+def check_path_conflict(input_path: str, output_path: str):
+    """Ensure input and output paths don't overlap."""
+    input_abs = Path(input_path).resolve()
+    output_abs = Path(output_path).resolve()
+    if output_abs.is_relative_to(input_abs):
+        raise ValueError("Input and output paths overlap. This may cause issues.")
diff --git a/examples/cli/README.md b/examples/cli/README.md
index 2927d4c..fcdbb4e 100644
--- a/examples/cli/README.md
+++ b/examples/cli/README.md
@@ -4,6 +4,8 @@ EnvCloak simplifies managing sensitive environment variables by encrypting and d
 
 ## Usage
 
+> **Dry Run:** With all required params **for each command** you can use `--dry-run` flag to check if command will pass or fail - without destroying your project 😅
+
 ### Key Generation
 
 #### 1. Generate a Key from a Password and Salt


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index ff380bb..7074d5a 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -324,6 +324,7 @@ def mock_encrypt(input_path, output_path, key):
     # Confirm that the temporary file is deleted by the CLI
     assert not os.path.exists(tmp_file), f"Temporary file {tmp_file} was not deleted"
 
+
 def test_encrypt_with_mixed_input_and_directory(runner, mock_files):
     """
     Test the `encrypt` CLI command with mixed `--input` and `--directory` usage.
diff --git a/tests/test_cli_dry_run.py b/tests/test_cli_dry_run.py
new file mode 100644
index 0000000..75b778c
--- /dev/null
+++ b/tests/test_cli_dry_run.py
@@ -0,0 +1,201 @@
+import os
+from pathlib import Path
+import pytest
+import shutil
+import tempfile
+from tests.test_cli import isolated_mock_files
+from click.testing import CliRunner
+from envcloak.cli import main
+
+
+@pytest.fixture
+def runner():
+    """
+    Fixture for Click CLI Runner.
+    """
+    return CliRunner()
+
+
+@pytest.fixture
+def isolated_mock_files():
+    """
+    Provide isolated mock files in a temporary directory for each test.
+    Prevents modification of the original mock files.
+    """
+    with tempfile.TemporaryDirectory() as temp_dir:
+        temp_dir_path = Path(temp_dir)
+        mock_dir = Path("tests/mock")
+
+        # Copy all mock files to the temporary directory
+        for file in mock_dir.iterdir():
+            if file.is_file():
+                shutil.copy(file, temp_dir_path / file.name)
+
+        yield temp_dir_path
+        # Cleanup is handled automatically by TemporaryDirectory
+
+
+@pytest.fixture
+def mock_files(isolated_mock_files):
+    """
+    Fixture for using isolated mock files for dry-run tests.
+    """
+    input_file = isolated_mock_files / "variables.env"
+    encrypted_file = isolated_mock_files / "variables.env.enc"
+    key_file = isolated_mock_files / "mykey.key"
+    directory = isolated_mock_files
+    return input_file, encrypted_file, key_file, directory
+
+
+def test_encrypt_dry_run_single_file(runner, mock_files):
+    """
+    Test the `encrypt` CLI command with a single input file in dry-run mode.
+    """
+    input_file, _, key_file, _ = mock_files
+    output_file = str(input_file) + ".enc"  # This file already exists in mock_files
+
+    result = runner.invoke(
+        main,
+        [
+            "encrypt",
+            "--input",
+            str(input_file),
+            "--output",
+            output_file,
+            "--key-file",
+            str(key_file),
+            "--dry-run",
+        ],
+    )
+
+    assert result.exit_code == 0
+    assert "Output path already exists" in result.output
+
+
+def test_encrypt_dry_run_directory(runner, mock_files):
+    """
+    Test the `encrypt` CLI command with a directory in dry-run mode.
+    """
+    _, _, key_file, directory = mock_files
+    output_directory = directory / "output"
+
+    result = runner.invoke(
+        main,
+        [
+            "encrypt",
+            "--directory",
+            str(directory),
+            "--output",
+            str(output_directory),
+            "--key-file",
+            str(key_file),
+            "--dry-run",
+        ],
+    )
+
+    assert result.exit_code == 0
+    assert "Dry-run checks passed successfully." in result.output
+    assert f"Input directory does not exist: {directory}" not in result.output
+
+
+def test_decrypt_dry_run_single_file(runner, mock_files):
+    """
+    Test the `decrypt` CLI command with a single input file in dry-run mode.
+    """
+    _, encrypted_file, key_file, _ = mock_files
+    output_file = str(encrypted_file).replace(".enc", ".decrypted")
+
+    result = runner.invoke(
+        main,
+        [
+            "decrypt",
+            "--input",
+            str(encrypted_file),
+            "--output",
+            output_file,
+            "--key-file",
+            str(key_file),
+            "--dry-run",
+        ],
+    )
+
+    assert result.exit_code == 0
+    assert "Dry-run checks passed successfully." in result.output
+    assert f"Encrypted file does not exist: {encrypted_file}" not in result.output
+
+
+def test_generate_key_dry_run(runner, isolated_mock_files):
+    """
+    Test the `generate-key` CLI command in dry-run mode.
+    """
+    key_file = isolated_mock_files / "random.key"
+
+    result = runner.invoke(
+        main,
+        [
+            "generate-key",
+            "--output",
+            str(key_file),
+            "--dry-run",
+        ],
+    )
+
+    assert result.exit_code == 0
+    assert "Dry-run checks passed successfully." in result.output
+    assert f"Output path already exists: {key_file}" not in result.output
+
+
+def test_generate_key_from_password_dry_run(runner, isolated_mock_files):
+    """
+    Test the `generate-key-from-password` CLI command in dry-run mode.
+    """
+    key_file = isolated_mock_files / "password.key"
+    password = "MySecretPassword"
+    salt = "a3b4c5d6e7f8f9a0a1b2c3d4e5f6a7b8"  # Valid 16-byte hex string
+
+    result = runner.invoke(
+        main,
+        [
+            "generate-key-from-password",
+            "--password",
+            password,
+            "--salt",
+            salt,
+            "--output",
+            str(key_file),
+            "--dry-run",
+        ],
+    )
+
+    assert result.exit_code == 0
+    assert "Dry-run checks passed successfully." in result.output
+
+
+def test_rotate_keys_dry_run(runner, mock_files):
+    """
+    Test the `rotate-keys` CLI command in dry-run mode.
+    """
+    _, encrypted_file, key_file, directory = mock_files
+    new_key_file = directory / "newkey.key"
+    new_key_file.write_bytes(os.urandom(32))
+    output_file = str(encrypted_file).replace(".enc", ".rotated")
+
+    result = runner.invoke(
+        main,
+        [
+            "rotate-keys",
+            "--input",
+            str(encrypted_file),
+            "--old-key-file",
+            str(key_file),
+            "--new-key-file",
+            str(new_key_file),
+            "--output",
+            output_file,
+            "--dry-run",
+        ],
+    )
+
+    assert result.exit_code == 0
+    assert "Dry-run checks passed successfully." in result.output
+    assert f"Encrypted file does not exist: {encrypted_file}" not in result.output
diff --git a/tests/test_exceptions_loader.py b/tests/test_exceptions_loader.py
index d7e5c03..96288a0 100644
--- a/tests/test_exceptions_loader.py
+++ b/tests/test_exceptions_loader.py
@@ -46,11 +46,13 @@ def test_file_decryption_exception():
     """
     loader = EncryptedEnvLoader("tests/mock/variables.env.enc", "tests/mock/mykey.key")
 
-    with patch("pathlib.Path.exists", return_value=True), patch(
-        "envcloak.loader.decrypt_file",
-        side_effect=FileDecryptionException("Decryption error"),
-    ), patch(
-        "builtins.open", mock_open(read_data="fake_key")
+    with (
+        patch("pathlib.Path.exists", return_value=True),
+        patch(
+            "envcloak.loader.decrypt_file",
+            side_effect=FileDecryptionException("Decryption error"),
+        ),
+        patch("builtins.open", mock_open(read_data="fake_key")),
     ):  # Simulate the key file
         with pytest.raises(EncryptedEnvLoaderException) as exc_info:
             loader.load()
@@ -66,10 +68,11 @@ def test_unsupported_file_format_exception():
     Test that UnsupportedFileFormatException is raised for unsupported file formats.
     """
     loader = EncryptedEnvLoader("tests/mock/variables.unknown", "tests/mock/mykey.key")
-    with patch("pathlib.Path.exists", return_value=True), patch(
-        "envcloak.loader.decrypt_file"
-    ), patch("builtins.open", mock_open(read_data="{}")), patch.object(
-        Path, "suffix", ".unknown"
+    with (
+        patch("pathlib.Path.exists", return_value=True),
+        patch("envcloak.loader.decrypt_file"),
+        patch("builtins.open", mock_open(read_data="{}")),
+        patch.object(Path, "suffix", ".unknown"),
     ):  # Mock the suffix attribute
         with pytest.raises(
             UnsupportedFileFormatException, match="File format detected: .unknown"
@@ -82,11 +85,13 @@ def test_unexpected_error():
     Test that EncryptedEnvLoaderException is raised for unexpected errors.
     """
     loader = EncryptedEnvLoader("test.enc", "test.key")
-    with patch("pathlib.Path.exists", return_value=True), patch(
-        "envcloak.loader.decrypt_file"
-    ), patch(
-        "envcloak.loader.EncryptedEnvLoader._parse_file",
-        side_effect=ValueError("Unexpected error"),
+    with (
+        patch("pathlib.Path.exists", return_value=True),
+        patch("envcloak.loader.decrypt_file"),
+        patch(
+            "envcloak.loader.EncryptedEnvLoader._parse_file",
+            side_effect=ValueError("Unexpected error"),
+        ),
     ):
         with pytest.raises(
             EncryptedEnvLoaderException,
@@ -100,10 +105,11 @@ def test_parse_file_error():
     Test that EncryptedEnvLoaderException is raised when parsing fails.
     """
     loader = EncryptedEnvLoader("test.json", "test.key")
-    with patch("pathlib.Path.exists", return_value=True), patch(
-        "envcloak.loader.decrypt_file"
-    ), patch("envcloak.loader.open", mock_open(read_data="invalid json")), patch(
-        "json.load", side_effect=ValueError("JSON parsing error")
+    with (
+        patch("pathlib.Path.exists", return_value=True),
+        patch("envcloak.loader.decrypt_file"),
+        patch("envcloak.loader.open", mock_open(read_data="invalid json")),
+        patch("json.load", side_effect=ValueError("JSON parsing error")),
     ):
         with pytest.raises(
             EncryptedEnvLoaderException, match="Failed to parse the decrypted file."
@@ -116,12 +122,11 @@ def test_parse_xml_error():
     Test that EncryptedEnvLoaderException is raised when XML parsing fails.
     """
     loader = EncryptedEnvLoader("tests/mock/variables.xml.enc", "tests/mock/mykey.key")
-    with patch("pathlib.Path.exists", return_value=True), patch(
-        "envcloak.loader.decrypt_file"
-    ), patch(
-        "envcloak.loader.safe_parse", side_effect=Exception("XML parsing error")
-    ), patch(
-        "builtins.open", mock_open(read_data="fake_key")
+    with (
+        patch("pathlib.Path.exists", return_value=True),
+        patch("envcloak.loader.decrypt_file"),
+        patch("envcloak.loader.safe_parse", side_effect=Exception("XML parsing error")),
+        patch("builtins.open", mock_open(read_data="fake_key")),
     ):  # Simulate the key file
         with pytest.raises(
             EncryptedEnvLoaderException, match="Failed to parse XML file."