Skip to content
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ repos:
language: system
files: (BUILD|BUILD\.bazel|WORKSPACE|\.bazelrc|[^/]+\.(py|bzl|bazel|yaml|yml|rs|c|cpp|h|hpp))$
pass_filenames: false
- id: test-doubles-naming-fix
name: Check and fix test double file names
entry: ./scripts/fix_test_doubles_naming_precommit.py
language: python
files: '\.(cc|cpp|h|hpp)$'
pass_filenames: true
- repo: https://github.com/pocc/pre-commit-hooks
rev: v1.3.5
hooks:
Expand Down
240 changes: 240 additions & 0 deletions scripts/fix_test_doubles_naming_precommit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
# *******************************************************************************
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# See the NOTICE file(s) distributed with this work for additional
# information regarding copyright ownership.
#
# This program and the accompanying materials are made available under the
# terms of the Apache License Version 2.0 which is available at
# https://www.apache.org/licenses/LICENSE-2.0
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
"""Check that test doubles (mocks, stubs. etc.) follow our agreed naming convention.

This script searches for test doubles and optionally fixes any doubles with non-conforming filenames.
This is designed to be compatible with precommit hooks, where the files to check are passed as
parameters.

Usage:
python3 ./fix_test_doubles_naming_precommit.py [-h] [--dry-run] [filenames ...]

Example:
python3 ./fix_test_doubles_naming_precommit.py --dry-run component_mock.cpp
"""

from __future__ import annotations
import logging
import sys
import argparse
import re
from pathlib import Path
from enum import Enum
from dataclasses import dataclass


class TestDoubleName(Enum):
MOCK = "mock"
STUB = "stub"
FAKE = "fake"

@staticmethod
def from_path(path: Path) -> TestDoubleName:
for double in TestDoubleName:
if double.value in str(path):
return double
raise NotImplementedError

@staticmethod
def to_regex_alternation() -> str:
return "(" + ("|".join([name.value for name in TestDoubleName]) + ")")


@dataclass(frozen=True)
class RenameOperation:
"""
Contains the necessary information to conduct a file rename operation
"""

source: Path
target: Path


def normalize_base_name(old_name: str) -> str:
"""
Remove the first occurrence of a test double name from a filename
"""
double_name = TestDoubleName.to_regex_alternation()
# Match the double name only when it sits between separators (`_`) or
# the start/end of the string, so e.g. "mockable" is left alone. The two
# boundary groups are captured so a shared separator (e.g. the "_" in
# "foo_mock_bar") is preserved rather than consumed twice; the double
# name itself (group 2) is dropped by omitting it from the replacement.
base = re.sub(rf"(?i)(^|[_]){double_name}($|[_])", r"\1\3", old_name, count=1)
# Collapse any doubled-up separator left behind (e.g. "foo__bar") and
# trim a leading/trailing one (e.g. from "mock_foo" or "foo_mock").
base = re.sub(r"[_]{2,}", "_", base)
return base.strip("_")


def build_target_name(path: Path) -> Path | None:
"""
Create a path containing the corrected name of the test double
"""
filename, extension = path.stem, path.suffix
test_double_name = TestDoubleName.from_path(path).value

base = normalize_base_name(filename)
# Build a new name using the test double as a prefix
target = f"{test_double_name}_{base}{extension}"

return path.with_name(target)


def is_double(path: Path) -> bool:
"""
Return true if the given path is a test double
"""
SUPPORTED_TEST_DOUBLE_NAMES = [name.value for name in TestDoubleName]
TEST_DOUBLE_SEARCH_PATTERNS = [
re.compile(rf"(?i)(^|[^a-z0-9]){name}([^a-z0-9]|$)")
for name in SUPPORTED_TEST_DOUBLE_NAMES
]
return any([r.search(str(path)) is not None for r in TEST_DOUBLE_SEARCH_PATTERNS])


def has_multiple_double_names(path: Path) -> bool:
"""
Return true if more than one test double names are present in a file name
"""
pattern = TestDoubleName.to_regex_alternation()
matches = re.findall(pattern, str(path), flags=re.IGNORECASE)
return len(matches) > 1


def define_operations(filenames: list[str]) -> list[RenameOperation]:
"""
Define all the rename operations required to make the source files conformant
"""
operations: list[RenameOperation] = []
for path_str in filenames:
path = Path(path_str)

# Skip paths that don't exist as files
if not path.exists() or not path.is_file():
logging.warning(f"Skipping {path}, it does not exist as a file.")
continue

# Skip files that are not test doubles
if not is_double(path):
logging.info(f"Ignoring {path}, it is not a recognised test double.")
continue

# Error if we find more than one test double name in a file name
if has_multiple_double_names(path):
raise ValueError(
f"Invalid file name {path} contains multiple double names."
)

# Error if there is more than one file extension
if len(path.suffixes) > 1:
raise ValueError(
f"More than one file extension found for test double {path}. Fix this!"
)

target_name = build_target_name(path)

# Skip files that are already named correctly
if path == target_name:
continue

operations.append(RenameOperation(source=path, target=target_name))
return operations


def apply_operations(operations: list[RenameOperation]) -> None:
"""
Do the renaming of the files
"""
for operation in operations:
operation.source.rename(operation.target)


def has_conflicts(operations: list[RenameOperation]):
"""
Check if any of the operations will conflict with a previous operation, or an existing file
"""
seen = {}
conflicts = 0

for operation in operations:
if operation.target.exists():
logging.error(
f"Conflict detected: Renaming {operation.source} to {operation.target} will overwrite an existing file"
)
conflicts += 1
if operation.target in seen:
conflicting_source = seen[operation.target]
logging.error(
f"Conflict detected: Renaming {operation.source} to {operation.target} will conflict with the rename from {conflicting_source} to {operation.target}"
)
conflicts += 1
else:
seen[operation.target] = operation.source

return conflicts > 0


def main() -> int:
parser = argparse.ArgumentParser(
description="Rename test double files to conform with a prefix format."
)
parser.add_argument(
"filenames",
nargs="*",
help="Files to check.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Do not make any changes to any files.",
)
args = parser.parse_args()

operations = define_operations(args.filenames)

if has_conflicts(operations):
logging.error("Conflicts detected, aborting.")
return 1

if not operations:
logging.info("No files need renaming.")
return 0

logging.warning("Non-conformant files have been found, fixing now.")
action = "Renaming"
if args.dry_run:
action += " (dry-run)"
for operation in operations:
logging.warning(f"{action}: {operation.source} -> {operation.target}.")

if not args.dry_run:
apply_operations(operations)
else:
logging.info("Dry run only. Re-run without --dry-run to perform changes.")

# We return success only if no rename operations were required
if operations:
return 1

return 0


if __name__ == "__main__":
logging.basicConfig(
format="%(levelname)s: %(message)s",
)
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
raise SystemExit(main())
Loading