Repository: MarketSquare/robotframework-robocop

Issue:
Cannot use custom-rules from TOML configuration file
Hello,

I am in the process of upgrading my test project from version 5 to 6, in the past we were able to include custom rules by using the following

```
[tool.robocop]
ext-rules = ["custom_rules.py"]
```

I migrated my TOML file using the provided migration command, nontheless this old "ext-rules" was simply removed, not migrated so I tried different options like

```
[tool.robocop.lint]
custom-rules = ["custom_rules.py"]
```
produces
_Unknown configuration key: 'custom_rules'_

or even inside configure but no luck

```
configure = [
    "custom-rules=custom_rules.py"
]
```
got
_InvalidConfigurationFormatError: 'custom-rules=custom_rules.py': Invalid configuration format. Pass parameters using name.param_name=value syntax._

is this an issue or am I doing something wrong? Thanks a lot in advance.

Hints from issue comments:
@bhirsz: The custom rules configuration seems to work but I have added additional later of verification, which by mistake didnt include custom rules key. I will fix it. @bhirsz: Also ``I migrated my TOML file using the provided migration command, nontheless this old "ext-rules" was simply removed, not migrated`` is a bug. I have tested it, and migrate command did not recognized options with hyphens (``-``) even if they are allowed. I will also fix it.@bhirsz: I believe the issue was connected to hyphen in option name. When I used ``custom_rules`` it worked. Can you try it? Obviously it's still a bug, as we allow both forms (target_version and target-version) so I have fixed it - it will be part of upcoming 6.1.0 release.@danielherrera: Thank you very much, working perfectly now!

Solution patch:
diff --git a/docs/releasenotes/6.1.0.rst b/docs/releasenotes/6.1.0.rst
new file mode 100644
index 0000000..5c1e4cc
--- /dev/null
+++ b/docs/releasenotes/6.1.0.rst
@@ -0,0 +1,35 @@
+:orphan:
+
+=============
+Robocop 6.1.0
+=============
+
+Fixes
+=====
+
+Some options are not recognized when used with hyphens (#1378)
+---------------------------------------------------------------
+
+It should be possible to use both variants of options in configuration file - with underscores or with hyphens (
+``target_version`` or ``target-version``).
+
+Options with hyphens in name are ignored by migrate command (#1378)
+-------------------------------------------------------------------
+
+Migrate command will now correctly convert option names that uses hyphens. For example ``ext-rules`` will be converted
+to ``custom_rules``.
+
+Acknowledgements
+================
+
+Thanks to the whole community for submitting bug reports and feature requests.
+Without you, Robocop wouldn't be in the place where it is now. All the feedback
+is essential to drive the tool towards higher quality and better user
+experience.
+
+If you want to help us more, consider contributing to the project directly.
+We can offer our constant support to make the work fun and effective. We do
+our best to create a supportive and welcoming environment for everyone.
+Feel free to ping us on our official `#robocop-linter Slack channel`_ anytime.
+
+.. _#robocop-linter Slack channel: https://robotframework.slack.com/archives/C01AWSNKC2H
\ No newline at end of file
diff --git a/src/robocop/config.py b/src/robocop/config.py
index 2b1bdca..d73e9e4 100644
--- a/src/robocop/config.py
+++ b/src/robocop/config.py
@@ -505,6 +505,11 @@ class FileFiltersOptions(ConfigContainer):
         return any(path.match(pattern) for pattern in self.included_paths)
 
 
+def normalize_config_keys(config: dict[str, ...]) -> dict[str, ...]:
+    """Allow to use target_version and target-version alternative names in configuration file."""
+    return {key.replace("-", "_"): value for key, value in config.items()}
+
+
 @dataclass
 class Config:
     sources: list[str] | None = field(default_factory=lambda: ["."])
@@ -531,17 +536,20 @@ class Config:
         If there is parent configuration, use it to overwrite loaded configuration.
         """
         # TODO: validate all key and types
+        config = normalize_config_keys(config)
         Config.validate_config(config, config_path)
         parsed_config = {
             "config_source": str(config_path),
-            "linter": LinterConfig.from_toml(config.pop("lint", {}), config_path),
+            "linter": LinterConfig.from_toml(normalize_config_keys(config.pop("lint", {})), config_path),
             "file_filters": FileFiltersOptions.from_toml(config),
             "language": config.pop("language", []),
             "verbose": config.pop("verbose", False),
         }
         if "target_version" in config:
             parsed_config["target_version"] = validate_target_version(config["target_version"])
-        parsed_config["formatter"] = FormatterConfig.from_toml(config.pop("format", {}), config_path.parent)
+        parsed_config["formatter"] = FormatterConfig.from_toml(
+            normalize_config_keys(config.pop("format", {})), config_path.parent
+        )
         parsed_config = {key: value for key, value in parsed_config.items() if value is not None}
         return cls(**parsed_config)
 
diff --git a/src/robocop/migrate_config.py b/src/robocop/migrate_config.py
index 4f728e0..3fc1270 100644
--- a/src/robocop/migrate_config.py
+++ b/src/robocop/migrate_config.py
@@ -274,7 +274,9 @@ def migrate_deprecated_configs(config_path: Path) -> None:
         print("No [tool] section found.")
         return
     robotidy_config = tool_config.get("robotidy", {})
+    robotidy_config = {key.replace("-", "_"): value for key, value in robotidy_config.items()}
     robocop_config = tool_config.get("robocop", {})
+    robocop_config = {key.replace("-", "_"): value for key, value in robocop_config.items()}
     if not robotidy_config and not robocop_config:
         print("No [tool.robocop] or [tool.robotidy] section found.")
         return


Test patch:
diff --git a/tests/config/test_config_manager.py b/tests/config/test_config_manager.py
index 9887c5c..355c3e2 100644
--- a/tests/config/test_config_manager.py
+++ b/tests/config/test_config_manager.py
@@ -86,9 +86,10 @@ class TestConfigFinder:
         # Assert
         assert actual_results == expected_results
 
-    def test_single_config_all_options(self, test_data):
+    @pytest.mark.parametrize("config_dir_name", ["config_with_all_options", "config_with_all_options_hyphens"])
+    def test_single_config_all_options(self, test_data, config_dir_name):
         # Arrange
-        config_dir = test_data / "config_with_all_options"
+        config_dir = test_data / config_dir_name
         config = Config()
         config.config_source = str(config_dir / "pyproject.toml")
         config.linter.configure = ["line-too-long.line_length=110"]
diff --git a/tests/config/test_data/config_with_all_options_hyphens/pyproject.toml b/tests/config/test_data/config_with_all_options_hyphens/pyproject.toml
new file mode 100644
index 0000000..35a57e8
--- /dev/null
+++ b/tests/config/test_data/config_with_all_options_hyphens/pyproject.toml
@@ -0,0 +1,65 @@
+[tool.robocop]
+exclude = ["deprecated.robot"]
+default-exclude = ["archived/"]
+include = ["custom.txt"]
+default-include = ["*.robot"]
+target-version = 6
+language = ["en", "pl"]
+# config = "other_config.toml"  # ignored, --config can be only passed via cli
+# ignore_git_dir = true  # ignored, only from cli
+# skip_gitignore = true  # ignored, only from cli, but may change in the future
+
+[tool.robocop.lint]
+select = [
+    "rulename",
+    "ruleid"
+]
+ignore = [
+    "ruleid"
+]
+threshold = "W"
+reports = ["all", "sarif"]
+issue-format = "{source_abs}:{line}:{col} [{severity}] {rule_id} {desc} ({name})"
+custom-rules = [
+    "CustomRules.py"
+]
+persistent = true
+compare = true
+exit-zero = true
+configure = [
+    "line-too-long.line_length=110"
+]
+
+[tool.robocop.format]
+select = ["NormalizeNewLines"]
+custom-formatters = ["CustomFormatter.py"]
+configure = [
+    "NormalizeNewLines.flatten_lines=True"
+]
+force-order = true
+diff = true
+overwrite = false
+check = true
+space-count = 2
+indent = 3
+continuation-indent = 5
+line-length = 110
+separator = "tab"
+line-ending = "windows"
+start-line = 10
+end-line = 12
+skip = [
+    "comments",
+    "documentation"
+]
+skip-sections = [
+    "comments"
+]
+skip-keyword-call = [
+    "DeprecatedKeyword"
+]
+skip-keyword-call-pattern = [
+    "DeprecatedKeyword$"
+]
+reruns = 5
+# TODO output
\ No newline at end of file
diff --git a/tests/migrate_config/test_data/common_hyphens.toml b/tests/migrate_config/test_data/common_hyphens.toml
new file mode 100644
index 0000000..de21c6f
--- /dev/null
+++ b/tests/migrate_config/test_data/common_hyphens.toml
@@ -0,0 +1,57 @@
+[tool.other_tool]
+flag = true
+
+[other_section]
+# empty
+
+[tool.robocop]
+paths = [
+    "tests\\atest\\rules\\bad-indent",
+    "tests\\atest\\rules\\duplicated-library"
+]
+include = ['W0504', '*doc*']
+exclude = ["0203", "too-many-arguments"]
+reports = [
+    "rules_by_id",
+    "scan_timer"
+]
+ignore = ["ignore_me.robot"]
+ext-rules = ["path_to_external\\dir"]
+filetypes = [".txt", ".tsv"]
+threshold = "E"
+format = "{source}:{line}:{col} [{severity}] {rule_id} {desc} (name)1{source_rel}"
+output = "robocop.log"
+configure = [
+    "line-too-long:line_length:150",
+    "0201:severity:E"
+]
+persistent = true
+no-recursive = true
+language = [
+    "pt",
+    "fi"
+]
+verbose = false
+
+# comment that will be discarded
+[tool.robotidy]
+transform = ["AlignKeywordsSection", "SplitTooLongLine:split_on_every_arg=False"]
+skip-return-values = true
+skip-tags = false
+diff = false
+overwrite = true
+separator = "space"
+spacecount = 4
+continuation-indent = 2
+exclude = "Regex\\s"
+line-length = 120
+lineseparator = "native"
+skip-gitignore = false
+ignore-git-dir = false
+target-version = "rf4"
+configure = [
+    "OrderSettings:keyword_before=documentation,tags,timeout,arguments:keyword_after=return",
+    "OrderSettingsSection:group_order=documentation,imports,settings,tags",
+    "OrderSettingsSection:imports_order=library,resource,variables",
+    "AlignKeywordsSection : skip_return_values = False"
+]
\ No newline at end of file
diff --git a/tests/migrate_config/test_migrate_config.py b/tests/migrate_config/test_migrate_config.py
index 416cf79..8128b3e 100644
--- a/tests/migrate_config/test_migrate_config.py
+++ b/tests/migrate_config/test_migrate_config.py
@@ -27,13 +27,14 @@ def display_file_diff(expected, actual):
         console.print(line, end="", highlight=False)
 
 
-def test_migrate_config(tmp_path):
-    config_path = TEST_DATA / "common.toml"
+@pytest.mark.parametrize("source_config", ["common", "common_hyphens"])
+def test_migrate_config(source_config, tmp_path):
+    config_path = TEST_DATA / f"{source_config}.toml"
     expected = TEST_DATA / "common_migrated.toml"
-    actual = tmp_path / "common_migrated.toml"
+    actual = tmp_path / f"{source_config}_migrated.toml"
     shutil.copy(config_path, tmp_path)
 
-    migrate_config(tmp_path / "common.toml")
+    migrate_config(tmp_path / f"{source_config}.toml")
 
     if not filecmp.cmp(expected, actual):
         display_file_diff(expected, actual)