diff --git a/docs/integrations/ai.md b/docs/integrations/ai.md index ec8bd7dc6..44dadf6f8 100644 --- a/docs/integrations/ai.md +++ b/docs/integrations/ai.md @@ -15,6 +15,7 @@ Once configured, you can ask your AI assistant things like: - "Format this test and show me what issues remain" - "What does the LEN01 rule check for?" - "Show me all naming-related rules" +- "Configure Robocop to allow longer lines and disable naming checks" ### Installation @@ -412,6 +413,125 @@ apply_fix( ) ``` +#### Natural Language Configuration Tools + +These tools enable AI assistants to configure Robocop through natural language. Users can describe what they want (e.g., "allow longer lines" or "disable naming checks") and the AI generates the appropriate configuration. + +The workflow is designed for **safe preview by default**: + +1. `get_config_context` - Get rule catalog and instructions for LLM processing +2. `parse_config_response` - Parse LLM response into validated configuration (preview only) +3. `apply_configuration` - Write configuration to file (explicit action required) + +##### get_config_context + +Get the system message and instructions for natural language configuration. This provides all available rules, their parameters, and instructions for generating configuration suggestions. + +**Parameters:** None + +**Returns:** Dictionary with: + +- `system_message`: Complete context including all rules organized by category, configurable parameters, and JSON response format instructions + +##### parse_config_response + +Parse an LLM's JSON response into validated configuration suggestions. This tool validates rule names, parameters, and values, returning ready-to-use TOML configuration. + +!!! note "Preview Only" + This tool has `readOnlyHint: True` - it does NOT write any files. The `toml_config` field contains the configuration for preview or manual use. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `llm_response` | string | The JSON response from the LLM after processing a natural language configuration request (required) | + +**Expected LLM Response Format:** + +```json +{ + "interpretation": "Brief summary of what you understood", + "suggestions": [ + { + "rule_id": "LEN02", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + "section": "lint", + "interpretation": "Allow lines up to 140 characters", + "explanation": "The line-too-long rule defaults to 120 chars" + } + ], + "warnings": ["any ambiguities or issues"] +} +``` + +**Returns:** Dictionary with: + +- `success`: Whether parsing was successful +- `suggestions`: List of validated configuration suggestions, each with: + - `rule_id`: Rule identifier (e.g., "LEN02") + - `rule_name`: Rule name (e.g., "line-too-long") + - `action`: One of "configure", "enable", "disable", or "set" + - `parameter`: Parameter name (for configure/set actions) + - `value`: Value to set + - `section`: Config section ("common", "lint", or "format") + - `interpretation`: What we understood the user meant + - `explanation`: Why this configuration is appropriate +- `toml_config`: Ready-to-use TOML configuration string +- `warnings`: Any ambiguities, conflicts, or issues found +- `explanation`: Summary of what the configuration achieves + +##### apply_configuration + +Apply Robocop configuration to a TOML file. This tool merges the new configuration with any existing settings. + +!!! warning "File Modification" + This tool writes to disk. Review the configuration using `parse_config_response` before applying. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `toml_config` | string | TOML configuration string to apply (required) | +| `file_path` | string | Path to configuration file (default: "pyproject.toml") | + +**Supported file formats:** + +- `pyproject.toml` / `robot.toml`: Uses `[tool.robocop.*]` sections +- `robocop.toml`: Uses root-level sections (`[lint]`, `[format]`) + +**Returns:** Dictionary with: + +- `success`: Whether the configuration was successfully applied +- `file_path`: Absolute path to the configuration file +- `file_created`: True if a new file was created +- `diff`: Unified diff showing changes made +- `merged_config`: The full configuration after merging +- `validation_passed`: True if configuration is valid +- `validation_error`: Error message if validation failed + +**Example workflow:** + +```python +# 1. Get context for LLM processing +context = get_config_context() +# context.system_message contains all rules and instructions + +# 2. User asks: "Allow lines up to 140 characters and disable the too-many-calls-in-test rule" +# AI processes the request using the system message and generates JSON + +# 3. Parse the AI's response (preview only - no file changes) +result = parse_config_response(llm_response='{"interpretation": "...", "suggestions": [...]}') +# result.toml_config contains: +# [tool.robocop.lint] +# configure = ["line-too-long.line_length=140"] +# ignore = ["too-many-calls-in-test"] + +# 4. User reviews and approves, then apply to file +apply_configuration( + toml_config=result.toml_config, + file_path="pyproject.toml" +) +``` + #### Discovery Tools ##### list_rules @@ -645,6 +765,25 @@ Once the MCP server is configured, you can use natural language to interact with > > "Help me fix all the issues in this file one by one" +#### Natural Language Configuration + +**Configure rules using natural language:** +> "Allow lines up to 140 characters and set indentation to 2 spaces" +> +> "Disable the naming rules for test cases" +> +> "I want to ignore the too-many-calls-in-test rule" + +**Preview before applying:** +> "Show me what configuration would allow longer keywords" +> +> "Generate config to disable documentation checks, but don't save it yet" + +**Apply to specific file:** +> "Configure Robocop to use 4-space indentation and save to pyproject.toml" +> +> "Add these rules to my robocop.toml file" + #### Configuring Rules **Custom thresholds:** diff --git a/pyproject.toml b/pyproject.toml index 4dd3a749d..93c690f2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ robocop-mcp = "robocop.mcp.server:main" [project.optional-dependencies] mcp = [ "fastmcp>=2.13.0; python_version >= '3.10'", + "tomlkit>=0.13.0; python_version >= '3.10'", ] [project.urls] diff --git a/src/robocop/mcp/tools/models.py b/src/robocop/mcp/tools/models.py index 6be24fb4f..7ad353409 100644 --- a/src/robocop/mcp/tools/models.py +++ b/src/robocop/mcp/tools/models.py @@ -412,3 +412,50 @@ class ApplyFixResult(BaseModel): default=None, description="Issues that remain after the fix (limited to first 10)" ) validation_error: str | None = Field(default=None, description="Error message if fix validation failed") + + +# --- Natural Language Configuration Models --- + + +class ConfigSuggestion(BaseModel): + """A single suggested configuration option from natural language input.""" + + rule_id: str | None = Field(default=None, description="Rule identifier (e.g., 'LEN02') - for rule-related actions") + rule_name: str | None = Field( + default=None, description="Rule name (e.g., 'line-too-long') - for rule-related actions" + ) + action: Literal["configure", "enable", "disable", "set"] = Field( + description="Action: configure (rule param), enable/disable (rule), set (scalar config option)" + ) + parameter: str | None = Field(default=None, description="Parameter name for 'configure' or option name for 'set'") + value: str | None = Field(default=None, description="Value for 'configure' or 'set' actions") + section: Literal["common", "lint", "format"] = Field( + default="lint", + description="Config section: common ([tool.robocop]), lint, or format", + ) + interpretation: str = Field(description="What we understood the user meant") + explanation: str = Field(description="Why this configuration is appropriate") + + +class NLConfigResult(BaseModel): + """Result of parsing natural language into Robocop configuration.""" + + success: bool = Field(description="Whether the parsing was successful") + suggestions: list[ConfigSuggestion] = Field(description="List of suggested configuration changes") + toml_config: str = Field( + description="Ready-to-use TOML configuration (may include multiple sections: common, lint, format)" + ) + warnings: list[str] = Field(default_factory=list, description="Ambiguities, conflicts, or unsupported features") + explanation: str = Field(description="Summary of what the configuration achieves") + + +class ApplyConfigurationResult(BaseModel): + """Result of applying configuration to a file.""" + + success: bool = Field(description="Whether the configuration was successfully applied") + file_path: str = Field(description="Path to the configuration file") + file_created: bool = Field(description="True if a new file was created") + diff: str | None = Field(default=None, description="Unified diff showing changes") + merged_config: str = Field(description="The full [tool.robocop.lint] section after merging") + validation_passed: bool = Field(description="True if Robocop accepts the configuration") + validation_error: str | None = Field(default=None, description="Error message if validation failed") diff --git a/src/robocop/mcp/tools/natural_language_config.py b/src/robocop/mcp/tools/natural_language_config.py new file mode 100644 index 000000000..05499afdf --- /dev/null +++ b/src/robocop/mcp/tools/natural_language_config.py @@ -0,0 +1,867 @@ +""" +Natural language configuration parser for Robocop. + +This module provides functions to parse natural language descriptions into +Robocop configuration suggestions. It builds a rule catalog and system message +for LLM-assisted configuration generation. +""" + +from __future__ import annotations + +import json +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any, TypedDict + +from robocop.mcp.tools.models import ApplyConfigurationResult, ConfigSuggestion, NLConfigResult +from robocop.mcp.tools.utils.constants import NESTED_CONFIG_SECTIONS +from robocop.mcp.tools.utils.helpers import ( + ConfigOptionsCatalog, + RuleCatalogEntry, + RuleParamInfo, + SuggestionDict, + build_config_options_catalog, + build_rule_catalog, + detect_config_conflicts, + generate_toml_config_from_suggestions, + get_rule_by_name_or_id, + validate_rule_param, +) +from robocop.mcp.tools.utils.toml_handler import ( + TOMLDecodeError, + append_config_to_file_content, + extract_all_sections_string, + generate_diff, + has_robocop_config, + is_robocop_toml, + merge_robocop_section, + parse_toml_string, + read_toml_file, + read_toml_file_as_string, + toml_to_string, +) + +# --- Type Definitions --- + + +class RawSuggestion(TypedDict, total=False): + """ + Raw suggestion from LLM response before validation. + + All fields are optional since we need to validate their presence. + """ + + rule_id: str + rule_name: str + action: str + parameter: str + value: str + section: str + interpretation: str + explanation: str + + +@dataclass(frozen=True, slots=True) +class ValidationResult: + """Result of validating raw suggestions from LLM response.""" + + suggestions: tuple[ConfigSuggestion, ...] + warnings: tuple[str, ...] + + @classmethod + def empty(cls, warnings: list[str] | None = None) -> ValidationResult: + """ + Create an empty result with optional warnings. + + Args: + warnings: Optional list of warning messages. + + Returns: + A ValidationResult with no suggestions. + + """ + return cls( + suggestions=(), + warnings=tuple(warnings) if warnings else (), + ) + + +@dataclass(frozen=True, slots=True) +class _SuggestionContext: + """Parsed context from a raw suggestion for validation.""" + + action: str + section: str + parameter: str | None + value: str | None + interpretation: str + explanation: str + + +# --- Helper Functions --- + + +def _exclude_keys(d: dict[str, Any], keys: frozenset[str]) -> dict[str, Any]: + """ + Return a copy of dict d without the specified keys. + + Args: + d: The source dictionary. + keys: Frozenset of keys to exclude. + + Returns: + A new dictionary with the specified keys removed. + + """ + return {k: v for k, v in d.items() if k not in keys} + + +def _format_rule_parameters(params: list[RuleParamInfo], rule_name: str) -> list[str]: + """ + Format rule parameters with an example configuration line. + + Args: + params: List of parameter info dicts with name, type, default keys. + rule_name: The rule name for the example. + + Returns: + List of formatted parameter lines. + + """ + lines: list[str] = [] + param_strs = [ + f"{p['name']} ({p['type']}, default={p['default']})" if p.get("default") else f"{p['name']} ({p['type']})" + for p in params + ] + lines.append(f" Parameters: {'; '.join(param_strs)}") + + # Add example configuration using the first parameter + first_param = params[0] + example_value = first_param.get("default", "value") + lines.append(f' Example: configure = ["{rule_name}.{first_param["name"]}={example_value}"]') + + return lines + + +def _format_rule_entry(rule: RuleCatalogEntry) -> list[str]: + """ + Format a single rule entry for the system message. + + Args: + rule: Rule catalog entry with rule_id, name, description, deprecated, enabled, parameters. + + Returns: + List of formatted lines for this rule. + + """ + lines: list[str] = [] + + deprecated_marker = " [DEPRECATED]" if rule.get("deprecated") else "" + enabled_marker = "" if rule.get("enabled") else " [disabled by default]" + lines.append(f"- {rule['rule_id']} ({rule['name']}){deprecated_marker}{enabled_marker}: {rule['description']}") + + params = rule.get("parameters", []) + if params: + lines.extend(_format_rule_parameters(params, rule["name"])) + + return lines + + +def _build_rules_section(rule_catalog: list[RuleCatalogEntry]) -> str: + """ + Build the rules section of the system message, grouped by category. + + Args: + rule_catalog: List of rule catalog entries from build_rule_catalog(). + + Returns: + Formatted string with all rules grouped by category. + + """ + # Group rules by category prefix (letters before digits) + categories: dict[str, list[RuleCatalogEntry]] = defaultdict(list) + for rule in rule_catalog: + category = "".join(c for c in rule["rule_id"] if c.isalpha()) + categories[category].append(rule) + + lines: list[str] = [] + for category in sorted(categories): + category_name = _get_category_name(category) + lines.append(f"\n## {category_name} ({category})") + + for rule in categories[category]: + lines.extend(_format_rule_entry(rule)) + + return "\n".join(lines) + + +def _build_config_options_section(config_catalog: ConfigOptionsCatalog) -> str: + """ + Build the configuration options section of the system message. + + Args: + config_catalog: Config options catalog with 'common', 'lint', 'format' sections. + + Returns: + Formatted string with all config options by section. + + """ + section_headers = [ + ("common", "## Common Options [tool.robocop]"), + ("lint", "\n## Lint Options [tool.robocop.lint]"), + ("format", "\n## Formatter Options [tool.robocop.format]"), + ] + + lines = ["\n# Configuration Options\n"] + for section_key, header in section_headers: + lines.append(header) + for opt in config_catalog.get(section_key, []): + lines.append(f"- {opt['name']}: {opt['type']} (default: {opt['default']})") + + return "\n".join(lines) + + +def _build_system_message( + rule_catalog: list[RuleCatalogEntry], + config_catalog: ConfigOptionsCatalog | None = None, +) -> str: + """ + Build the system message with rule catalog and config options for LLM context. + + Args: + rule_catalog: The rule catalog from build_rule_catalog(). + config_catalog: The config options catalog from build_config_options_catalog(). + + Returns: + A formatted system message string with all rules, config options, and instructions. + + """ + rules_text = _build_rules_section(rule_catalog) + config_section = _build_config_options_section(config_catalog) if config_catalog else "" + + return f"""You are a Robocop configuration assistant. +Robocop is a static code analyzer and formatter for Robot Framework with {len(rule_catalog)} rules. + +# Available Rules +{rules_text} +{config_section} + +# Configuration Format + +TOML syntax uses different sections: +- [tool.robocop] for common options (cache, language, verbose, etc.) +- [tool.robocop.lint] for linter rules and settings +- [tool.robocop.format] for formatter settings (space_count, indent, line_length, etc.) + +## Rule Configuration (in [tool.robocop.lint]): +1. Configure rule parameters: + configure = ["rule-name.param=value", "another-rule.param=value"] +2. Enable specific rules: + select = ["RULE_ID", "another-rule"] +3. Disable rules: + ignore = ["RULE_ID", "another-rule"] + +## Scalar Options (use action="set"): +- cache_dir = "/path/to/cache" +- target_version = 7 +- line_length = 120 +- space_count = 4 + +# Your Task + +Parse the user's natural language request and suggest configuration changes. + +Rules: +- Match user descriptions to specific rules by name, ID, or description +- For general config options (cache, formatting settings), use action="set" +- If the user wants to "allow" something, they usually want to increase a limit or disable a rule +- If the user wants to "enforce" or "require" something, they want to enable or configure a rule +- Handle ambiguous requests by picking the most likely match and noting the ambiguity + +Respond with ONLY valid JSON (no markdown code blocks, no extra text): +{{ + "interpretation": "Brief summary of what you understood the user wanted", + "suggestions": [ + {{ + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + "section": "lint", + "interpretation": "Allow lines up to 140 characters", + "explanation": "The line-too-long rule defaults to 120 chars; setting to 140 as requested" + }}, + {{ + "action": "set", + "parameter": "cache_dir", + "value": "/tmp/robocop-cache", + "section": "common", + "interpretation": "Set cache directory", + "explanation": "Store cache files in the specified directory" + }} + ], + "warnings": ["any ambiguities, assumptions, or issues to note"] +}} + +Actions: +- "configure": Set a rule parameter (requires rule_id, rule_name, parameter, value, section="lint") +- "enable": Add rule to select list (requires rule_id, rule_name, section="lint") +- "disable": Add rule to ignore list (requires rule_id, rule_name, section="lint") +- "set": Set a scalar config option (requires parameter, value, section="common"|"lint"|"format") + +# CLI-Only Options (NOT configurable via TOML) +Some options are only available via command-line flags and CANNOT be set in configuration files: +- --ignore-git-dir: Ignore .git directories when finding project root +- --ignore-file-config: Don't load configuration files +- --skip-gitignore: Don't skip files listed in .gitignore +- --force-exclude: Enforce exclusions for directly passed paths +- --config: Path to configuration file +- --root: Project root directory + +If user asks about these, add a warning that they are CLI-only options.""" + + +def _get_category_name(category: str) -> str: + """ + Get a human-readable name for a rule category. + + Args: + category: The category prefix (e.g., "LEN", "NAME"). + + Returns: + A human-readable category name. + + """ + category_names = { + "ARG": "Argument Rules", + "COM": "Comment Rules", + "DEPR": "Deprecation Rules", + "DOC": "Documentation Rules", + "DUP": "Duplication Rules", + "ERR": "Error Rules", + "IMP": "Import Rules", + "KW": "Keyword Rules", + "LEN": "Length Rules", + "MISC": "Miscellaneous Rules", + "NAME": "Naming Rules", + "ORD": "Ordering Rules", + "SPC": "Spacing Rules", + "TAG": "Tag Rules", + "VAR": "Variable Rules", + "ANN": "Annotation Rules", + } + return category_names.get(category, f"{category} Rules") + + +def _strip_markdown_code_block(text: str) -> str: + """ + Remove markdown code block delimiters if present. + + Handles both ``` and ~~~ style code blocks, with optional language specifier. + + Args: + text: The text potentially wrapped in markdown code blocks. + + Returns: + The text with code block delimiters removed. + + """ + lines = text.strip().split("\n") + if not lines: + return text + + # Check for opening delimiter (``` or ~~~, optionally followed by language) + first_line = lines[0].strip() + if first_line.startswith(("```", "~~~")): + delimiter = first_line[:3] + lines = lines[1:] # Remove opening line + + # Check for closing delimiter + if lines and lines[-1].strip() == delimiter: + lines = lines[:-1] + + return "\n".join(lines) + + +def _parse_llm_response(response: str) -> tuple[dict[str, Any], str | None]: + """ + Parse the LLM's JSON response. + + Args: + response: The raw response string from the LLM. + + Returns: + A tuple of (parsed_dict, error_message). + If parsing fails, returns ({}, error_message). + + """ + cleaned = _strip_markdown_code_block(response) + + try: + return json.loads(cleaned), None + except json.JSONDecodeError as e: + return {}, f"Failed to parse LLM response as JSON: {e}" + + +def _validate_suggestions(raw_suggestions: list[RawSuggestion]) -> ValidationResult: + """ + Validate and convert raw suggestions to ConfigSuggestion models. + + Args: + raw_suggestions: List of suggestion dicts from LLM response. + + Returns: + ValidationResult containing valid suggestions and any warnings. + + """ + valid_suggestions: list[ConfigSuggestion] = [] + warnings: list[str] = [] + + for raw in raw_suggestions: + suggestion = _validate_single_suggestion(raw, warnings) + if suggestion is not None: + valid_suggestions.append(suggestion) + + return ValidationResult( + suggestions=tuple(valid_suggestions), + warnings=tuple(warnings), + ) + + +def _parse_suggestion_context(raw: RawSuggestion, warnings: list[str]) -> _SuggestionContext: + """ + Parse and validate the common fields from a raw suggestion. + + Args: + raw: Raw suggestion dict from LLM response. + warnings: List to append warnings to (mutated in place). + + Returns: + Parsed suggestion context with validated section. + + """ + section = raw.get("section", "lint") + + # Validate section + if section not in {"common", "lint", "format"}: + warnings.append(f"Invalid section '{section}', defaulting to 'lint'") + section = "lint" + + return _SuggestionContext( + action=raw.get("action", "configure"), + section=section, + parameter=raw.get("parameter"), + value=raw.get("value"), + interpretation=raw.get("interpretation", ""), + explanation=raw.get("explanation", ""), + ) + + +def _validate_single_suggestion( + raw: RawSuggestion, + warnings: list[str], +) -> ConfigSuggestion | None: + """ + Validate a single raw suggestion and convert to ConfigSuggestion. + + Args: + raw: Raw suggestion dict from LLM response. + warnings: List to append warnings to (mutated in place). + + Returns: + ConfigSuggestion if valid, None if invalid. + + """ + ctx = _parse_suggestion_context(raw, warnings) + + # Handle "set" action for scalar config options + if ctx.action == "set": + return _validate_set_action(ctx, warnings) + + # For rule-based actions (configure, enable, disable) + return _validate_rule_action(raw, ctx, warnings) + + +def _validate_set_action( + ctx: _SuggestionContext, + warnings: list[str], +) -> ConfigSuggestion | None: + """ + Validate and create a 'set' action suggestion. + + Args: + ctx: Parsed suggestion context. + warnings: List to append warnings to. + + Returns: + ConfigSuggestion if valid, None if invalid. + + """ + if not ctx.parameter or ctx.value is None: + warnings.append("Set action missing parameter or value, skipping") + return None + + return ConfigSuggestion( + rule_id=None, + rule_name=None, + action="set", + parameter=ctx.parameter, + value=str(ctx.value), + section=ctx.section, + interpretation=ctx.interpretation, + explanation=ctx.explanation, + ) + + +def _validate_rule_action( + raw: RawSuggestion, + ctx: _SuggestionContext, + warnings: list[str], +) -> ConfigSuggestion | None: + """ + Validate and create a rule-based action suggestion. + + Args: + raw: Raw suggestion dict (for rule_id/rule_name). + ctx: Parsed suggestion context. + warnings: List to append warnings to. + + Returns: + ConfigSuggestion if valid, None if invalid. + + """ + rule_id = raw.get("rule_id", "") + rule_name = raw.get("rule_name", "") + + # Validate rule exists + rule = get_rule_by_name_or_id(rule_name) or get_rule_by_name_or_id(rule_id) + if rule is None: + warnings.append(f"Rule '{rule_name or rule_id}' not found, skipping") + return None + + # Use canonical names + canonical_id = rule.rule_id + canonical_name = rule.name + + # Validate action + if ctx.action not in {"configure", "enable", "disable"}: + warnings.append(f"Invalid action '{ctx.action}' for rule '{canonical_name}', skipping") + return None + + # Validate parameter for configure action + if ctx.action == "configure": + if not ctx.parameter or not ctx.value: + warnings.append(f"Configure action for '{canonical_name}' missing parameter or value, skipping") + return None + + is_valid, _, error = validate_rule_param(canonical_name, ctx.parameter, str(ctx.value)) + if not is_valid: + warnings.append(error or f"Invalid parameter configuration for '{canonical_name}'") + return None + + # Check for deprecated rules + if rule.deprecated: + warnings.append(f"Rule '{canonical_name}' ({canonical_id}) is deprecated") + + return ConfigSuggestion( + rule_id=canonical_id, + rule_name=canonical_name, + action=ctx.action, + parameter=ctx.parameter if ctx.action == "configure" else None, + value=str(ctx.value) if ctx.action == "configure" and ctx.value else None, + section=ctx.section, + interpretation=ctx.interpretation, + explanation=ctx.explanation, + ) + + +def _parse_config_impl( + llm_response: str | None = None, +) -> NLConfigResult: + """ + Parse LLM response into configuration suggestions. + + This function validates and converts the LLM's JSON response + into structured configuration suggestions. + + Args: + llm_response: The JSON response from the LLM after processing user's description. + + Returns: + NLConfigResult with suggestions and TOML config. + + """ + warnings: list[str] = [] + + if llm_response is None: + # No LLM response provided - this shouldn't happen in normal flow + # The host LLM should process the description using the system message + return NLConfigResult( + success=False, + suggestions=[], + toml_config="", + warnings=["No LLM response provided. Use the system message to process the description."], + explanation="Configuration parsing requires LLM processing.", + ) + + # Parse the LLM response + parsed, parse_error = _parse_llm_response(llm_response) + if parse_error: + warnings.append(parse_error) + return NLConfigResult( + success=False, + suggestions=[], + toml_config="", + warnings=warnings, + explanation="Failed to parse configuration suggestions.", + ) + + # Extract and validate suggestions + raw_suggestions = parsed.get("suggestions", []) + interpretation = parsed.get("interpretation", "") + llm_warnings = parsed.get("warnings", []) + + validation_result = _validate_suggestions(raw_suggestions) + warnings.extend(llm_warnings) + warnings.extend(validation_result.warnings) + + # Detect conflicts (only for rule-based suggestions) + suggestion_dicts: list[SuggestionDict] = [ + { + "rule_id": s.rule_id or "", + "rule_name": s.rule_name or "", + "action": s.action, + "parameter": s.parameter or "", + "value": s.value or "", + "section": s.section, + } + for s in validation_result.suggestions + ] + conflict_warnings = detect_config_conflicts(suggestion_dicts) + warnings.extend(conflict_warnings) + + # Generate TOML config for all sections + config_sections = generate_toml_config_from_suggestions(suggestion_dicts) + toml_config = extract_all_sections_string(config_sections) + + return NLConfigResult( + success=len(validation_result.suggestions) > 0, + suggestions=list(validation_result.suggestions), + toml_config=toml_config, + warnings=warnings, + explanation=interpretation or "Generated configuration from natural language description.", + ) + + +def get_config_system_message() -> str: + """ + Get the system message for natural language configuration. + + This is used by the MCP tool to provide context to the host LLM. + + Returns: + The system message string with all rules, config options, and instructions. + + """ + rule_catalog = build_rule_catalog() + config_catalog = build_config_options_catalog() + return _build_system_message(rule_catalog, config_catalog) + + +def parse_config_from_llm_response(llm_response: str) -> NLConfigResult: + """ + Parse an LLM response into configuration suggestions. + + This is the main entry point for the MCP tool after the host LLM + has processed the user's description. + + Args: + llm_response: The JSON response from the LLM. + + Returns: + NLConfigResult with validated suggestions and TOML config. + + """ + return _parse_config_impl(llm_response=llm_response) + + +def _validate_configure_entries(entries: list[str]) -> str | None: + """ + Validate configure entry formats (rule.param=value). + + Returns: + error message if invalid, None if all valid. + + """ + for entry in entries: + try: + _rule_name, param_value = entry.split(".", maxsplit=1) + _param, _value = param_value.split("=", maxsplit=1) + except ValueError: + return f"Invalid configure entry format: {entry}" + return None + + +def _parse_toml_config_all_sections( + toml_config: str, +) -> tuple[dict[str, dict[str, Any]], str | None]: + """ + Parse TOML config string into section dicts. Returns (sections, error). + + Handles both formats: + - [tool.robocop.*] (for pyproject.toml/robot.toml) + - Root level / [lint] / [format] (for robocop.toml) + + Returns: + A tuple of (sections_dict, error). sections_dict has keys 'common', 'lint', 'format'. + + """ + sections: dict[str, dict[str, Any]] = {"common": {}, "lint": {}, "format": {}} + + try: + config = parse_toml_string(toml_config) + + # Try [tool.robocop.*] first (pyproject.toml/robot.toml format) + robocop_config = config.get("tool", {}).get("robocop", {}) + if robocop_config: + # Extract sections from tool.robocop + sections["lint"] = robocop_config.get("lint", {}) + sections["format"] = robocop_config.get("format", {}) + sections["common"] = _exclude_keys(robocop_config, NESTED_CONFIG_SECTIONS) + return sections, None + + # Try root level sections (robocop.toml format) + sections["lint"] = config.get("lint", {}) + sections["format"] = config.get("format", {}) + sections["common"] = _exclude_keys(config, NESTED_CONFIG_SECTIONS) + + return sections, None + except TOMLDecodeError as e: + return sections, f"Invalid TOML: {e}" + + +def _extract_display_sections(config: dict[str, Any], use_root_level: bool) -> dict[str, dict[str, Any]]: + """ + Extract display sections from a merged config. + + Args: + config: The merged configuration dictionary. + use_root_level: If True, sections are at root level (robocop.toml format). + If False, sections are under tool.robocop (pyproject.toml format). + + Returns: + A dict with 'common', 'lint', and 'format' sections. + + """ + if use_root_level: + source = config + else: + source = config.get("tool", {}).get("robocop", {}) + + return { + "common": _exclude_keys(source, NESTED_CONFIG_SECTIONS), + "lint": source.get("lint", {}), + "format": source.get("format", {}), + } + + +def _make_error_result(file_path: Path, error: str) -> ApplyConfigurationResult: + """ + Create a standardized error result for configuration application. + + Args: + file_path: The path to the configuration file. + error: The error message. + + Returns: + ApplyConfigurationResult indicating failure. + + """ + return ApplyConfigurationResult( + success=False, + file_path=str(file_path), + file_created=False, + diff=None, + merged_config="", + validation_passed=False, + validation_error=error, + ) + + +def _apply_config_impl( + toml_config: str, + file_path: str = "pyproject.toml", +) -> ApplyConfigurationResult: + """ + Apply configuration to a file. + + Supports three configuration file formats: + - pyproject.toml: Uses [tool.robocop.*] sections + - robot.toml: Uses [tool.robocop.*] sections + - robocop.toml: Uses root level / [lint] / [format] (no [tool.robocop] prefix) + + Args: + toml_config: The TOML configuration string to apply. + file_path: Path to the configuration file (default: pyproject.toml). + + Returns: + ApplyConfigurationResult with success status and details. + + """ + path = Path(file_path).resolve() + file_existed = path.exists() + use_root_level = is_robocop_toml(path) + + try: + # Parse new config into sections + new_sections, parse_error = _parse_toml_config_all_sections(toml_config) + if parse_error: + return _make_error_result(path, parse_error) + + # Read existing file content + existing_toml = read_toml_file_as_string(path) + existing_config = read_toml_file(path) + + # Check if robocop config already exists + robocop_config_exists = has_robocop_config(existing_config, use_root_level) + + if robocop_config_exists: + # Robocop config exists: merge in place using tomlkit (preserves location and comments) + for section_name, section_config in new_sections.items(): + if section_config: + merge_robocop_section(existing_config, section_config, section_name, use_root_level) + merged_toml = toml_to_string(existing_config) + else: + # No robocop config: append new config at end of file (minimal diff) + new_config_str = extract_all_sections_string(new_sections, use_root_level=use_root_level) + merged_toml = append_config_to_file_content(existing_toml, new_config_str) + # Re-parse to get the merged config for validation + existing_config = parse_toml_string(merged_toml) + + # Generate diff and write file + diff = generate_diff(existing_toml, merged_toml, path.name) + path.write_text(merged_toml, encoding="utf-8") + + # Extract all sections for display + display_sections = _extract_display_sections(existing_config, use_root_level) + + # Validate configure entries in lint section + lint_section = display_sections.get("lint", {}) + validation_error = _validate_configure_entries(lint_section.get("configure", [])) + + return ApplyConfigurationResult( + success=True, + file_path=str(path), + file_created=not file_existed, + diff=diff, + merged_config=extract_all_sections_string(display_sections, use_root_level=use_root_level), + validation_passed=validation_error is None, + validation_error=validation_error, + ) + + except PermissionError: + return _make_error_result(path, f"Permission denied: Cannot write to {path}") + except OSError as e: + return _make_error_result(path, str(e)) diff --git a/src/robocop/mcp/tools/registration.py b/src/robocop/mcp/tools/registration.py index bb6460d71..7c7e67ae8 100644 --- a/src/robocop/mcp/tools/registration.py +++ b/src/robocop/mcp/tools/registration.py @@ -6,10 +6,7 @@ from fastmcp.server.context import Context from pydantic import Field -from robocop.mcp.tools.batch_operations import ( - _format_files_impl, - _lint_files_impl, -) +from robocop.mcp.tools.batch_operations import _format_files_impl, _lint_files_impl from robocop.mcp.tools.diagnostics import ( _explain_issue_impl, _get_statistics_impl, @@ -24,13 +21,15 @@ _list_rules_impl, _search_rules_impl, ) -from robocop.mcp.tools.fixing import ( - _apply_fix_impl, - _get_fix_context_impl, +from robocop.mcp.tools.fixing import _apply_fix_impl, _get_fix_context_impl +from robocop.mcp.tools.formatting import ( + _format_content_impl, + _format_file_impl, + _lint_and_format_impl, ) -from robocop.mcp.tools.formatting import _format_content_impl, _format_file_impl, _lint_and_format_impl from robocop.mcp.tools.linting import _lint_content_impl, _lint_file_impl from robocop.mcp.tools.models import ( + ApplyConfigurationResult, ApplyFixResult, DiagnosticResult, ExplainIssueResult, @@ -44,6 +43,7 @@ GetStatisticsResult, LintAndFormatResult, LintFilesResult, + NLConfigResult, PromptSummary, RuleDetail, RuleSearchResult, @@ -51,6 +51,11 @@ SuggestFixesResult, WorstFilesResult, ) +from robocop.mcp.tools.natural_language_config import ( + _apply_config_impl, + get_config_system_message, + parse_config_from_llm_response, +) def register_tools(mcp: FastMCP) -> None: @@ -867,3 +872,168 @@ async def apply_fix( await ctx.info(msg) return result + + @mcp.tool( + tags={"configuration", "natural-language"}, + annotations={"readOnlyHint": True, "title": "Get Configuration Context for Natural Language"}, + ) + async def get_config_context( + ctx: Context | None = None, + ) -> dict[str, str]: + """ + Get the system message and instructions for natural language configuration. + + This tool provides the context needed to parse natural language descriptions + into Robocop configuration. Use this to understand available rules and + configuration options before calling parse_config_response. + + Workflow: + 1. Call get_config_context() to get the system message + 2. Use the system message to process the user's natural language request + 3. Call parse_config_response() with the LLM's JSON response + 4. Review suggestions with the user + 5. Call apply_configuration() to write to pyproject.toml + + Returns: + A dict with 'system_message' containing all rules and instructions. + + """ + if ctx: + await ctx.info("Building rule catalog for configuration context...") + + system_message = get_config_system_message() + + if ctx: + await ctx.info("Configuration context ready") + + return {"system_message": system_message} + + @mcp.tool( + tags={"configuration", "natural-language"}, + annotations={"readOnlyHint": True, "title": "Parse Configuration Response"}, + ) + async def parse_config_response( + llm_response: Annotated[ + str, + Field( + description="The JSON response from the LLM after processing a natural language configuration request" + ), + ], + ctx: Context | None = None, + ) -> NLConfigResult: + """ + Parse an LLM's JSON response into validated configuration suggestions. + + After getting context from get_config_context() and having the LLM process + a user's natural language request, call this tool with the LLM's JSON response + to get validated configuration suggestions. + + The LLM response should be JSON with this structure: + { + "interpretation": "What the user wanted", + "suggestions": [ + { + "rule_id": "LEN02", + "rule_name": "line-too-long", + "action": "configure", // or "enable" or "disable" + "parameter": "line_length", // for "configure" action + "value": "140", // for "configure" action + "interpretation": "Allow 140 char lines", + "explanation": "Default is 120" + } + ], + "warnings": [] + } + + IMPORTANT: After calling this tool, you MUST: + 1. Show the user the generated toml_config and list of suggestions + 2. Ask the user for explicit confirmation before applying + 3. Only call apply_configuration() if the user approves + + Returns: + Validated suggestions and ready-to-use TOML configuration. + + Example:: + + # After LLM processes "allow longer lines up to 140 characters" + result = parse_config_response('{"interpretation": "...", "suggestions": [...]}') + # IMPORTANT: Show result.toml_config to user and ask for confirmation + # before calling apply_configuration() + + """ + if ctx: + await ctx.info("Parsing and validating configuration response...") + + result = parse_config_from_llm_response(llm_response) + + if ctx: + if result.success: + await ctx.info(f"Generated {len(result.suggestions)} configuration suggestion(s)") + else: + await ctx.info("Failed to parse configuration response") + if result.warnings: + for warning in result.warnings[:3]: # Show first 3 warnings + await ctx.debug(f"Warning: {warning}") + + return result + + @mcp.tool( + tags={"configuration"}, + annotations={"title": "Apply Configuration to File"}, + ) + async def apply_configuration( + toml_config: Annotated[ + str, + Field(description="TOML configuration string (e.g., from parse_config_response().toml_config)"), + ], + file_path: Annotated[ + str, + Field(description="Path to configuration file (default: pyproject.toml)"), + ] = "pyproject.toml", + ctx: Context | None = None, + ) -> ApplyConfigurationResult: + """ + Apply Robocop configuration to a TOML file. + + WARNING: This tool MODIFIES files on disk. The configuration will be merged + with any existing [tool.robocop] sections in the target file. + + IMPORTANT: Do NOT call this tool without explicit user confirmation! + Always show the user the toml_config content first and ask if they want + to apply it. Only proceed if the user explicitly confirms. + + Example workflow:: + + # 1. Call parse_config_response() to get validated config + result = parse_config_response(llm_json) + + # 2. Show result.toml_config to user and ASK FOR CONFIRMATION + # "Here's the configuration that will be applied: ... Apply? (yes/no)" + + # 3. Only if user confirms, call apply_configuration() + apply_configuration(result.toml_config, "pyproject.toml") + + Returns: + Result with success status, file path, whether created, + validation status, and any errors. + + """ + if ctx: + await ctx.info(f"Applying configuration to {file_path}...") + + result = _apply_config_impl(toml_config, file_path) + + if ctx: + if result.success: + if result.file_created: + await ctx.info(f"Created new file: {result.file_path}") + else: + await ctx.info(f"Updated: {result.file_path}") + if result.validation_passed: + await ctx.info("Configuration validated successfully") + else: + await ctx.info(f"Validation warning: {result.validation_error}") + else: + await ctx.info(f"Failed to apply configuration: {result.validation_error}") + + return result diff --git a/src/robocop/mcp/tools/utils/constants.py b/src/robocop/mcp/tools/utils/constants.py index 1f569e64a..feffb2197 100644 --- a/src/robocop/mcp/tools/utils/constants.py +++ b/src/robocop/mcp/tools/utils/constants.py @@ -19,3 +19,10 @@ # Valid group_by options for batch linting VALID_GROUP_BY = frozenset(("severity", "rule", "file")) + +# Configuration section names for natural language config +CONFIG_SECTIONS = ("common", "lint", "format") +NESTED_CONFIG_SECTIONS = frozenset(("lint", "format")) + +# Robocop configuration file names +CONFIG_NAMES = frozenset(("robocop.toml", "pyproject.toml", "robot.toml")) diff --git a/src/robocop/mcp/tools/utils/helpers.py b/src/robocop/mcp/tools/utils/helpers.py index df5ca3e59..84a266d58 100644 --- a/src/robocop/mcp/tools/utils/helpers.py +++ b/src/robocop/mcp/tools/utils/helpers.py @@ -2,19 +2,78 @@ from __future__ import annotations +import operator import tempfile +import types from contextlib import contextmanager +from dataclasses import dataclass, field +from functools import cache from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, TypedDict, Union, get_args, get_origin from fastmcp.exceptions import ToolError from robocop.mcp.tools.models import DiagnosticResult, RuleDetail from robocop.mcp.tools.models import RuleParam as RuleParamModel -from robocop.mcp.tools.utils.constants import THRESHOLD_MAP, VALID_EXTENSIONS +from robocop.mcp.tools.utils.constants import ( + CONFIG_SECTIONS, + THRESHOLD_MAP, + VALID_EXTENSIONS, +) + +# --- Type Definitions for Catalog Structures --- + + +class RuleParamInfo(TypedDict): + """Parameter information for a rule in the catalog.""" + + name: str + type: str + default: str + description: str + + +class RuleCatalogEntry(TypedDict): + """Entry in the rule catalog returned by build_rule_catalog().""" + + rule_id: str + name: str + description: str + severity: str + enabled: bool + deprecated: bool + parameters: list[RuleParamInfo] + + +class ConfigOptionInfo(TypedDict): + """Configuration option information for the config catalog.""" + + name: str + type: str + default: str + + +class SuggestionDict(TypedDict, total=False): + """ + Suggestion dict used for conflict detection and TOML generation. + + All fields are optional since different actions require different fields. + """ + + rule_id: str + rule_name: str + action: str + parameter: str + value: str + section: str + + +# Type alias for the config options catalog +ConfigOptionsCatalog = dict[str, list[ConfigOptionInfo]] if TYPE_CHECKING: from collections.abc import Generator + from dataclasses import Field from robocop.linter.diagnostics import Diagnostic from robocop.linter.rules import Rule, RuleParam, RuleSeverity @@ -131,7 +190,7 @@ def _temp_robot_file(content: str, suffix: str) -> Generator[Path, None, None]: The path to the temporary file. """ - with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as tmp: + with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8") as tmp: tmp.write(content) tmp_path = Path(tmp.name) try: @@ -203,7 +262,473 @@ def _rule_to_dict(rule: Rule) -> RuleDetail: enabled=rule.enabled, deprecated=rule.deprecated, docs=rule.docs, - parameters=[_param_to_dict(p) for p in rule.parameters] if rule.parameters else [], + parameters=([_param_to_dict(p) for p in rule.parameters] if rule.parameters else []), added_in_version=rule.added_in_version, version_requirement=rule.version or None, ) + + +# --- Natural Language Configuration Helpers --- + + +@dataclass +class _RuleLookupCache: + """Lazy-initialized cache for O(1) rule lookups by ID or name.""" + + _by_id: dict[str, Rule] | None = field(default=None, init=False) + _by_name: dict[str, Rule] | None = field(default=None, init=False) + + def _ensure_populated(self) -> None: + """Populate the lookup tables on first access.""" + if self._by_id is not None: + return + + from robocop.mcp.cache import get_linter_config + + linter_config = get_linter_config() + self._by_id = {} + self._by_name = {} + + for rule in linter_config.rules.values(): + # Store by uppercase ID and lowercase name for case-insensitive lookup + self._by_id[rule.rule_id.upper()] = rule + self._by_name[rule.name.lower()] = rule + + def get(self, identifier: str) -> Rule | None: + """ + Find a rule by its name or ID. + + Args: + identifier: The rule name or ID (case-insensitive). + + Returns: + The Rule object if found, None otherwise. + + """ + self._ensure_populated() + assert self._by_id is not None + assert self._by_name is not None + + # Try ID lookup first (uppercase), then name lookup (lowercase) + return self._by_id.get(identifier.upper()) or self._by_name.get(identifier.lower()) + + +# Module-level cache instance +_rule_cache = _RuleLookupCache() + + +def get_rule_by_name_or_id(identifier: str) -> Rule | None: + """ + Find a rule by its name or ID (cached O(1) lookup). + + Args: + identifier: The rule name (e.g., "line-too-long") or ID (e.g., "LEN02"). + + Returns: + The Rule object if found, None otherwise. + + """ + return _rule_cache.get(identifier) + + +def validate_rule_param(rule_name: str, param_name: str, value: str) -> tuple[bool, str | None, str | None]: + """ + Validate and convert a parameter value for a rule. + + Args: + rule_name: The rule name or ID. + param_name: The parameter name to configure. + value: The string value to validate and convert. + + Returns: + A tuple of (is_valid, converted_value_str, error_message). + - is_valid: True if the value is valid for this parameter. + - converted_value_str: The validated value as a string, or None if invalid. + - error_message: An error message if invalid, None otherwise. + + """ + rule = get_rule_by_name_or_id(rule_name) + if rule is None: + return False, None, f"Rule '{rule_name}' not found" + + # Find the parameter in the rule's config + if param_name not in rule.config: + available_params = [p for p in rule.config if p != "severity"] + return ( + False, + None, + f"Parameter '{param_name}' not found for rule '{rule.name}'. Available: {available_params}", + ) + + param = rule.config[param_name] + + try: + # Use the parameter's converter to validate the value + param.converter(value) + # Return the string representation of the converted value + return True, str(value), None + except (ValueError, TypeError) as e: + return False, None, f"Invalid value '{value}' for parameter '{param_name}': {e}" + except Exception as e: # noqa: BLE001 - converter may raise any exception + return ( + False, + None, + f"Error converting value '{value}' for parameter '{param_name}': {e}", + ) + + +def detect_config_conflicts(suggestions: list[SuggestionDict]) -> list[str]: + """ + Detect conflicts in configuration suggestions. + + Conflicts include: + - Enabling and disabling the same rule + - Configuring a parameter on a disabled rule + + Args: + suggestions: List of suggestion dicts with action, rule_id, rule_name, etc. + + Returns: + List of warning messages for detected conflicts. + + """ + warnings = [] + + enabled_rules: set[str] = set() + disabled_rules: set[str] = set() + configured_rules: set[str] = set() + + for suggestion in suggestions: + rule_id = suggestion.get("rule_id", "") + action = suggestion.get("action", "") + + if action == "enable": + enabled_rules.add(rule_id) + elif action == "disable": + disabled_rules.add(rule_id) + elif action == "configure": + configured_rules.add(rule_id) + + # Check for enable/disable conflicts + conflicts = enabled_rules & disabled_rules + for rule_id in conflicts: + warnings.append(f"Conflict: Rule '{rule_id}' is both enabled and disabled in the same configuration") + + # Check for configuring disabled rules (just a warning, still valid) + configured_disabled = configured_rules & disabled_rules + for rule_id in configured_disabled: + warnings.append(f"Warning: Rule '{rule_id}' is configured but also disabled") + + return warnings + + +@cache +def build_rule_catalog() -> list[RuleCatalogEntry]: + """ + Build a catalog of all rules with moderate detail for LLM context. + + Results are cached since rules don't change during a session. + + Returns: + List of rule dictionaries with: + - rule_id: The rule ID (e.g., "LEN02") + - name: The rule name (e.g., "line-too-long") + - description: One-sentence description + - severity: Default severity ("I", "W", or "E") + - enabled: Whether enabled by default + - deprecated: Whether the rule is deprecated + - parameters: List of parameter dicts with name, type, default, description + + """ + from robocop.mcp.cache import get_linter_config + + linter_config = get_linter_config() + seen_ids: set[str] = set() + catalog = [] + + for rule in linter_config.rules.values(): + if rule.rule_id in seen_ids: + continue + seen_ids.add(rule.rule_id) + + # Extract first sentence of docs as description + description = "" + if rule.docs: + first_line = rule.docs.strip().split("\n")[0].strip() + description = first_line[:200] if len(first_line) > 200 else first_line + + # Build parameter list (exclude 'severity' which all rules have) + params = [] + if rule.parameters: + for param in rule.parameters: + params.append( + { + "name": param.name, + "type": param.param_type, + "default": (str(param.raw_value) if param.raw_value is not None else ""), + "description": param.desc or "", + } + ) + + catalog.append( + { + "rule_id": rule.rule_id, + "name": rule.name, + "description": description, + "severity": rule.severity.value, + "enabled": rule.enabled, + "deprecated": rule.deprecated, + "parameters": params, + } + ) + + # Sort by rule_id for consistent output + catalog.sort(key=operator.itemgetter("rule_id")) + return catalog + + +@cache +def build_config_options_catalog() -> ConfigOptionsCatalog: + """ + Build a catalog of all configuration options by introspecting Robocop's dataclasses. + + Dynamically extracts config options from Config, LinterConfig, FormatterConfig, + and related classes so that new options are automatically discovered. + + Results are cached since config options don't change during a session. + + Returns: + Dict with keys 'common', 'lint', 'format', each containing a list of option dicts. + Each option dict has: name, type, default, description (if available). + + """ + from dataclasses import MISSING, fields + + from robocop.config import ( + CacheConfig, + Config, + FileFiltersOptions, + FormatterConfig, + LinterConfig, + WhitespaceConfig, + ) + + catalog: ConfigOptionsCatalog = { + "common": [], # [tool.robocop] + "lint": [], # [tool.robocop.lint] + "format": [], # [tool.robocop.format] + } + + def get_default_str(f: Field[Any]) -> str: + """ + Extract default value as string from a dataclass field. + + Args: + f: The dataclass field. + + Returns: + The default value as a string, or indication if required. + + """ + if f.default is not MISSING: + return str(f.default) + if f.default_factory is not MISSING: + try: + val = f.default_factory() + if isinstance(val, set): + return str(list(val)[:3]) + "..." if len(val) > 3 else str(list(val)) + return str(val) + except (TypeError, ValueError): + return "(factory)" + return "(required)" + + def get_type_str(type_hint: Any) -> str: + """ + Convert type hint to readable string using typing introspection. + + Args: + type_hint: The type hint to convert. + + Returns: + Readable string representation of the type hint. + + """ + origin = get_origin(type_hint) + args = get_args(type_hint) + + # Handle Union types (including X | None from PEP 604) + if origin is Union or isinstance(type_hint, types.UnionType): + non_none = [a for a in args if a is not type(None)] + # Optional[X] shown as "X?" + if len(non_none) == 1 and type(None) in args: + return f"{get_type_str(non_none[0])}?" + return " | ".join(get_type_str(a) for a in args) + + # Handle generic types like list[str], dict[str, int] + if origin is not None: + origin_name = getattr(origin, "__name__", str(origin)) + if args: + args_str = ", ".join(get_type_str(a) for a in args) + return f"{origin_name}[{args_str}]" + return origin_name + + # Simple type with __name__ (int, str, bool, etc.) + if hasattr(type_hint, "__name__"): + return type_hint.__name__ + + # Fallback to string representation + return str(type_hint).replace("typing.", "").replace("", "") + + # Fields to exclude from each class (internal/nested/non-configurable) + exclude_fields = { + Config: { + "linter", + "formatter", + "file_filters", + "cache", + "languages", + "sources", + "config_source", + }, + LinterConfig: { + "rules", + "checkers", + "config_source", + "include_rules", + "include_rules_patterns", + "exclude_rules", + "exclude_rules_patterns", + }, + FormatterConfig: { + "whitespace_config", + "skip_config", + "formatters", + }, + FileFiltersOptions: set(), + CacheConfig: set(), + WhitespaceConfig: set(), + } + + # Map classes to their config sections + section_classes: dict[str, list[type]] = { + "common": [Config, FileFiltersOptions, CacheConfig], + "lint": [LinterConfig], + "format": [FormatterConfig, WhitespaceConfig], + } + + for section, classes in section_classes.items(): + for cls in classes: + excludes = exclude_fields.get(cls, set()) + for f in fields(cls): + # Skip internal fields + if f.name.startswith("_"): + continue + # Skip excluded fields + if f.name in excludes: + continue + # Skip fields with compare=False (typically internal) + if not f.compare: + continue + + catalog[section].append( + { + "name": f.name, + "type": get_type_str(f.type), + "default": get_default_str(f), + } + ) + + return catalog + + +def generate_toml_config_from_suggestions( + suggestions: list[SuggestionDict], +) -> dict[str, dict[str, Any]]: + """ + Generate TOML configuration dicts from suggestions for all sections. + + Args: + suggestions: List of suggestion dicts with keys: + - action: "configure", "enable", "disable", or "set" + - section: "common", "lint", or "format" + - rule_id, rule_name: For rule-related actions + - parameter, value: For configure/set actions + + Returns: + Dict with keys 'common', 'lint', 'format', each containing config for that section. + + """ + config: dict[str, dict[str, Any]] = { + "common": {}, # [tool.robocop] + "lint": {}, # [tool.robocop.lint] + "format": {}, # [tool.robocop.format] + } + + # Track list-based entries per section + configure_entries: dict[str, list[str]] = {"common": [], "lint": [], "format": []} + select_entries: dict[str, list[str]] = {"common": [], "lint": [], "format": []} + ignore_entries: dict[str, list[str]] = {"common": [], "lint": [], "format": []} + + for suggestion in suggestions: + action = suggestion.get("action", "") + section = suggestion.get("section", "lint") + rule_name = suggestion.get("rule_name", "") + rule_id = suggestion.get("rule_id", "") + parameter = suggestion.get("parameter", "") + value = suggestion.get("value", "") + + if action == "configure" and parameter and value: + # Rule parameter configuration: rule.param=value + configure_entries[section].append(f"{rule_name}.{parameter}={value}") + elif action == "enable" and rule_id: + select_entries[section].append(rule_id) + elif action == "disable" and rule_id: + ignore_entries[section].append(rule_id) + elif action == "set" and parameter and value is not None: + # Scalar config option: directly set in section + # Try to convert to appropriate type + config[section][parameter] = _convert_config_value(value) + + # Add list entries to each section + for section in CONFIG_SECTIONS: + if configure_entries[section]: + config[section]["configure"] = configure_entries[section] + if select_entries[section]: + config[section]["select"] = select_entries[section] + if ignore_entries[section]: + config[section]["ignore"] = ignore_entries[section] + + return config + + +def _convert_config_value(value: str) -> bool | int | float | str: + """ + Convert a string config value to the appropriate Python type. + + Args: + value: The string value to convert. + + Returns: + The converted value (bool, int, float, or str). + + """ + # Handle booleans + if value.lower() in {"true", "yes", "on"}: + return True + if value.lower() in {"false", "no", "off"}: + return False + + # Handle integers (try int first for values like "4" to stay as int) + try: + return int(value) + except ValueError: + pass + + # Handle floats (for values like "4.5" or "1e-3") + try: + return float(value) + except ValueError: + pass + + # Return as string + return value diff --git a/src/robocop/mcp/tools/utils/toml_handler.py b/src/robocop/mcp/tools/utils/toml_handler.py new file mode 100644 index 000000000..2fd38fb5a --- /dev/null +++ b/src/robocop/mcp/tools/utils/toml_handler.py @@ -0,0 +1,455 @@ +""" +TOML file handler for natural language configuration. + +Provides utilities for reading, merging, and writing TOML configuration files, +specifically for Robocop's configuration sections. + +Uses tomlkit to preserve comments and formatting when modifying existing files. + +Robocop supports three configuration file formats: +- pyproject.toml: Uses [tool.robocop] section +- robot.toml: Uses [tool.robocop] section +- robocop.toml: Uses root level (no [tool.robocop] prefix needed), but also accepts [tool.robocop] +""" + +from __future__ import annotations + +import difflib +from pathlib import Path # noqa: TC003 - used at runtime +from typing import Any + +import tomlkit +from tomlkit import TOMLDocument +from tomlkit.exceptions import TOMLKitError + +from robocop.mcp.tools.utils.constants import CONFIG_NAMES + +# Re-export for backwards compatibility +TOMLDecodeError = TOMLKitError + +__all__ = ["CONFIG_NAMES", "TOMLDecodeError"] + + +def is_robocop_toml(path: Path) -> bool: + """ + Check if the path is a robocop.toml file (uses root-level config). + + Args: + path: Path to the TOML file. + + Returns: + True if the file is named robocop.toml, False otherwise. + + """ + return path.name == "robocop.toml" + + +def read_toml_file(path: Path) -> TOMLDocument: + """ + Read and parse a TOML file, preserving comments and formatting. + + Args: + path: Path to the TOML file. + + Returns: + Parsed TOML as a TOMLDocument (dict-like). Returns empty document if file doesn't exist. + + """ + if not path.exists(): + return tomlkit.document() + + with path.open("r", encoding="utf-8") as f: + return tomlkit.parse(f.read()) + + +def read_toml_file_as_string(path: Path) -> str: + """ + Read a TOML file as a raw string. + + Args: + path: Path to the TOML file. + + Returns: + File contents as string, or empty string if file doesn't exist. + + """ + if not path.exists(): + return "" + + with path.open("r", encoding="utf-8") as f: + return f.read() + + +def write_toml_file(path: Path, config: TOMLDocument | dict[str, Any]) -> None: + """ + Write a TOML document to a file, preserving comments and formatting. + + Args: + path: Path to the TOML file. + config: TOMLDocument or dictionary to write as TOML. + + """ + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + f.write(tomlkit.dumps(config)) + + +def merge_robocop_config( + existing: TOMLDocument | dict[str, Any], + new_lint_config: dict[str, Any], + use_root_level: bool = False, +) -> TOMLDocument | dict[str, Any]: + """ + Merge new Robocop lint configuration with existing config. + + This function merges configuration at the appropriate level based on file type: + - For pyproject.toml/robot.toml: [tool.robocop.lint] + - For robocop.toml: [lint] at root level + + Intelligently combines lists (like `configure`, `select`, `ignore`) + and overwrites scalar values. Preserves comments and formatting. + + Args: + existing: The existing full TOML config (may be empty). + new_lint_config: The new lint configuration to merge (just the lint section contents). + use_root_level: If True, use root level [lint] section (for robocop.toml). + If False, use [tool.robocop.lint] (for pyproject.toml/robot.toml). + + Returns: + The merged configuration with the lint section updated. + + """ + return merge_robocop_section(existing, new_lint_config, "lint", use_root_level) + + +def merge_robocop_section( + existing: TOMLDocument | dict[str, Any], + new_config: dict[str, Any], + section: str = "lint", + use_root_level: bool = False, +) -> TOMLDocument | dict[str, Any]: + """ + Merge new Robocop configuration with existing config for any section. + + This function merges configuration at the appropriate level based on file type and section: + - For pyproject.toml/robot.toml: [tool.robocop], [tool.robocop.lint], [tool.robocop.format] + - For robocop.toml: root level, [lint], [format] + + Preserves comments and formatting in the existing document. + + Args: + existing: The existing full TOML config (may be empty). + new_config: The new configuration to merge (just the section contents). + section: Which section to merge into: "common" (root), "lint", or "format". + use_root_level: If True, use root level (for robocop.toml). + If False, use [tool.robocop.*] (for pyproject.toml/robot.toml). + + Returns: + The merged configuration with the section updated. + + """ + # Work with the existing document to preserve comments + existing_section = _get_or_create_section(existing, section, use_root_level) + _merge_section_values(existing_section, new_config) + return existing + + +def _get_or_create_section(config: TOMLDocument | dict[str, Any], section: str, use_root_level: bool) -> dict[str, Any]: + """ + Get or create the appropriate section in the config dict. + + Args: + config: The configuration dictionary to modify in place. + section: Which section: "common", "lint", or "format". + use_root_level: If True, use root level paths. + + Returns: + Reference to the section dict (modifying it modifies config). + + """ + if use_root_level: + # For robocop.toml: root level or [lint]/[format] + if section == "common": + return config + if section not in config: + config[section] = tomlkit.table() + return config[section] + + # For pyproject.toml/robot.toml: [tool.robocop.*] + if "tool" not in config: + config["tool"] = tomlkit.table() + if "robocop" not in config["tool"]: + config["tool"]["robocop"] = tomlkit.table() + + if section == "common": + return config["tool"]["robocop"] + + if section not in config["tool"]["robocop"]: + config["tool"]["robocop"][section] = tomlkit.table() + return config["tool"]["robocop"][section] + + +def _merge_section_values(existing: dict[str, Any], new_config: dict[str, Any]) -> None: + """ + Merge new values into existing section, handling lists specially. + + Args: + existing: The existing section dict (modified in place). + new_config: The new values to merge. + + """ + # List fields that should be merged (not replaced) + list_fields = { + "configure", + "select", + "extend_select", + "ignore", + "custom_rules", + "reports", + "include", + "exclude", + "language", + } + + for key, new_value in new_config.items(): + if key in list_fields and isinstance(new_value, list): + # Merge lists, avoiding duplicates while preserving order + existing_values = existing.get(key, []) + if not isinstance(existing_values, list): + existing_values = [existing_values] if existing_values else [] + # Convert to regular list for manipulation + existing_values = list(existing_values) + + # For 'configure', we need smarter merging (same rule.param should be replaced) + if key == "configure": + merged = _merge_configure_lists(existing_values, new_value) + else: + # Simple deduplication for other lists + seen = set() + merged = [] + for item in existing_values + new_value: + if item not in seen: + seen.add(item) + merged.append(item) + existing[key] = merged + else: + # Scalar values: new overwrites existing + existing[key] = new_value + + +def _merge_configure_lists(existing: list[str], new: list[str]) -> list[str]: + """ + Merge configure lists, replacing entries for the same rule.param. + + For example, if existing has "line-too-long.line_length=120" and new has + "line-too-long.line_length=140", the result will have only the new value. + + Args: + existing: Existing configure entries. + new: New configure entries. + + Returns: + Merged list with newer values taking precedence. + + """ + # Build a dict keyed by "rule.param" to track the latest value + config_dict: dict[str, str] = {} + + for entry in existing: + key = _get_configure_key(entry) + config_dict[key] = entry + + for entry in new: + key = _get_configure_key(entry) + config_dict[key] = entry # New value overwrites + + return list(config_dict.values()) + + +def _get_configure_key(entry: str) -> str: + """ + Extract the rule.param key from a configure entry. + + Args: + entry: A configure entry like "rule-name.param=value" + + Returns: + The key "rule-name.param" or the full entry if malformed. + + """ + if "=" in entry: + return entry.split("=", 1)[0] + return entry + + +def generate_diff(before: str, after: str, filename: str = "pyproject.toml") -> str | None: + """ + Generate a unified diff between two strings. + + Args: + before: The original content. + after: The modified content. + filename: The filename to show in the diff header. + + Returns: + A unified diff string, or None if there are no changes. + + """ + before_lines = before.splitlines(keepends=True) + after_lines = after.splitlines(keepends=True) + + diff_lines = list( + difflib.unified_diff( + before_lines, + after_lines, + fromfile=f"a/{filename}", + tofile=f"b/{filename}", + ) + ) + + if not diff_lines: + return None + + return "".join(diff_lines) + + +def toml_to_string(config: TOMLDocument | dict[str, Any]) -> str: + """ + Convert a dictionary or TOMLDocument to a TOML string. + + Args: + config: Dictionary or TOMLDocument to convert. + + Returns: + TOML-formatted string. + + """ + return tomlkit.dumps(config) + + +def parse_toml_string(toml_str: str) -> TOMLDocument: + """ + Parse a TOML string into a TOMLDocument. + + Args: + toml_str: The TOML string to parse. + + Returns: + Parsed TOMLDocument. Raises TOMLKitError if the string is invalid TOML. + + """ + return tomlkit.parse(toml_str) + + +def extract_lint_section_string(lint_config: dict[str, Any], use_root_level: bool = False) -> str: + """ + Generate a TOML string for the lint section. + + Args: + lint_config: The lint configuration dictionary (contents only, not nested). + use_root_level: If True, generate [lint] section (for robocop.toml). + If False, generate [tool.robocop.lint] (for pyproject.toml/robot.toml). + + Returns: + A TOML string with the appropriate header. + + """ + if not lint_config: + return "[lint]\n" if use_root_level else "[tool.robocop.lint]\n" + + return toml_to_string({"lint": lint_config} if use_root_level else {"tool": {"robocop": {"lint": lint_config}}}) + + +def extract_all_sections_string(config_sections: dict[str, dict[str, Any]], use_root_level: bool = False) -> str: + """ + Generate a TOML string for all Robocop config sections. + + Args: + config_sections: Dict with keys 'common', 'lint', 'format', each containing config. + use_root_level: If True, generate root level sections (for robocop.toml). + If False, generate [tool.robocop.*] (for pyproject.toml/robot.toml). + + Returns: + A TOML string with all non-empty sections. + + """ + common = config_sections.get("common", {}) + lint = config_sections.get("lint", {}) + fmt = config_sections.get("format", {}) + + if use_root_level: + # robocop.toml: root level for common, [lint] for lint, [format] for format + result: dict[str, Any] = {} + result.update(common) + if lint: + result["lint"] = lint + if fmt: + result["format"] = fmt + return toml_to_string(result) if result else "" + + # pyproject.toml/robot.toml: [tool.robocop.*] + robocop_section: dict[str, Any] = {} + robocop_section.update(common) + if lint: + robocop_section["lint"] = lint + if fmt: + robocop_section["format"] = fmt + + if not robocop_section: + return "" + + return toml_to_string({"tool": {"robocop": robocop_section}}) + + +def has_robocop_config(config: TOMLDocument | dict[str, Any], use_root_level: bool) -> bool: + """ + Check if Robocop configuration already exists in the config. + + Args: + config: The parsed TOML configuration. + use_root_level: If True, check for root-level [lint]/[format] (robocop.toml). + If False, check for [tool.robocop] (pyproject.toml/robot.toml). + + Returns: + True if Robocop config exists, False otherwise. + + """ + if use_root_level: + # For robocop.toml, check for [lint] or [format] sections + return "lint" in config or "format" in config + + # For pyproject.toml/robot.toml, check for [tool.robocop] + tool = config.get("tool", {}) + return "robocop" in tool + + +def append_config_to_file_content(existing_content: str, new_config: str) -> str: + """ + Append new configuration to the end of existing file content. + + Ensures proper formatting with appropriate newlines between sections. + + Args: + existing_content: The existing file content (may be empty). + new_config: The new TOML configuration to append. + + Returns: + The combined content with new config appended at the end. + + """ + if not existing_content: + return new_config + + if not new_config: + return existing_content + + # Ensure existing content ends with newline + result = existing_content + if not result.endswith("\n"): + result += "\n" + + # Add blank line before new section for readability (if not already present) + if not result.endswith("\n\n"): + result += "\n" + + return result + new_config diff --git a/tests/mcp/test_natural_language_config.py b/tests/mcp/test_natural_language_config.py new file mode 100644 index 000000000..62740a25b --- /dev/null +++ b/tests/mcp/test_natural_language_config.py @@ -0,0 +1,1457 @@ +"""Tests for natural language configuration feature.""" + +from __future__ import annotations + +import json +import os +import stat +import sys +import tempfile +from pathlib import Path + +import pytest + +from robocop.mcp.tools.natural_language_config import ( + _apply_config_impl, + _parse_llm_response, + _validate_suggestions, + get_config_system_message, + parse_config_from_llm_response, +) +from robocop.mcp.tools.utils.helpers import ( + build_config_options_catalog, + build_rule_catalog, + detect_config_conflicts, + generate_toml_config_from_suggestions, + get_rule_by_name_or_id, + validate_rule_param, +) +from robocop.mcp.tools.utils.toml_handler import ( + extract_lint_section_string, + generate_diff, + merge_robocop_config, + read_toml_file, + write_toml_file, +) + + +class TestBuildRuleCatalog: + """Tests for build_rule_catalog function.""" + + def test_catalog_contains_rules(self): + """Catalog should contain all available rules.""" + catalog = build_rule_catalog() + assert len(catalog) > 100, "Should have many rules" + + def test_catalog_rule_structure(self): + """Each rule in catalog should have expected fields.""" + catalog = build_rule_catalog() + rule = catalog[0] + + assert "rule_id" in rule + assert "name" in rule + assert "description" in rule + assert "severity" in rule + assert "enabled" in rule + assert "deprecated" in rule + assert "parameters" in rule + + def test_catalog_sorted_by_rule_id(self): + """Catalog should be sorted by rule_id.""" + catalog = build_rule_catalog() + rule_ids = [r["rule_id"] for r in catalog] + assert rule_ids == sorted(rule_ids) + + def test_catalog_parameters_structure(self): + """Rule parameters should have expected fields.""" + catalog = build_rule_catalog() + # Find a rule with parameters + rules_with_params = [r for r in catalog if r["parameters"]] + assert len(rules_with_params) > 0, "Should have rules with parameters" + + param = rules_with_params[0]["parameters"][0] + assert "name" in param + assert "type" in param + assert "default" in param + assert "description" in param + + +class TestGetRuleByNameOrId: + """Tests for get_rule_by_name_or_id function.""" + + def test_find_by_name(self): + """Should find rule by name.""" + rule = get_rule_by_name_or_id("line-too-long") + assert rule is not None + assert rule.name == "line-too-long" + + def test_find_by_id(self): + """Should find rule by ID.""" + rule = get_rule_by_name_or_id("LEN08") + assert rule is not None + assert rule.rule_id == "LEN08" + + def test_find_by_id_case_insensitive(self): + """Should find rule by ID case-insensitively.""" + rule = get_rule_by_name_or_id("len08") + assert rule is not None + assert rule.rule_id == "LEN08" + + def test_not_found(self): + """Should return None for non-existent rule.""" + rule = get_rule_by_name_or_id("nonexistent-rule") + assert rule is None + + +class TestValidateRuleParam: + """Tests for validate_rule_param function.""" + + def test_valid_int_param(self): + """Should accept valid integer parameter.""" + is_valid, value, error = validate_rule_param("line-too-long", "line_length", "140") + assert is_valid is True + assert value == "140" + assert error is None + + def test_invalid_param_name(self): + """Should reject invalid parameter name.""" + is_valid, value, error = validate_rule_param("line-too-long", "invalid_param", "100") + assert is_valid is False + assert value is None + assert "not found" in error + + def test_invalid_rule_name(self): + """Should reject invalid rule name.""" + is_valid, value, error = validate_rule_param("nonexistent-rule", "param", "100") + assert is_valid is False + assert value is None + assert "not found" in error + + def test_invalid_value_type(self): + """Should reject invalid value type.""" + is_valid, value, error = validate_rule_param("line-too-long", "line_length", "not-a-number") + assert is_valid is False + assert value is None + assert error is not None + + +class TestDetectConfigConflicts: + """Tests for detect_config_conflicts function.""" + + def test_no_conflicts(self): + """Should return empty list when no conflicts.""" + suggestions = [ + {"rule_id": "LEN01", "action": "configure"}, + {"rule_id": "LEN02", "action": "disable"}, + ] + warnings = detect_config_conflicts(suggestions) + assert warnings == [] + + def test_enable_disable_conflict(self): + """Should detect enable/disable conflict.""" + suggestions = [ + {"rule_id": "LEN01", "action": "enable"}, + {"rule_id": "LEN01", "action": "disable"}, + ] + warnings = detect_config_conflicts(suggestions) + assert len(warnings) == 1 + assert "Conflict" in warnings[0] + assert "LEN01" in warnings[0] + + def test_configure_disabled_warning(self): + """Should warn about configuring disabled rule.""" + suggestions = [ + {"rule_id": "LEN01", "action": "configure"}, + {"rule_id": "LEN01", "action": "disable"}, + ] + warnings = detect_config_conflicts(suggestions) + assert len(warnings) == 1 + assert "configured but also disabled" in warnings[0] + + +class TestGenerateTomlConfigFromSuggestions: + """Tests for generate_toml_config_from_suggestions function.""" + + def test_configure_action(self): + """Should generate configure entries in lint section.""" + suggestions = [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + } + ] + config = generate_toml_config_from_suggestions(suggestions) + assert "lint" in config + assert "configure" in config["lint"] + assert "line-too-long.line_length=140" in config["lint"]["configure"] + + def test_enable_action(self): + """Should generate select entries for enable action in lint section.""" + suggestions = [{"rule_id": "LEN01", "rule_name": "too-long-keyword", "action": "enable"}] + config = generate_toml_config_from_suggestions(suggestions) + assert "lint" in config + assert "select" in config["lint"] + assert "LEN01" in config["lint"]["select"] + + def test_disable_action(self): + """Should generate ignore entries for disable action in lint section.""" + suggestions = [{"rule_id": "LEN01", "rule_name": "too-long-keyword", "action": "disable"}] + config = generate_toml_config_from_suggestions(suggestions) + assert "lint" in config + assert "ignore" in config["lint"] + assert "LEN01" in config["lint"]["ignore"] + + def test_mixed_actions(self): + """Should handle multiple action types in lint section.""" + suggestions = [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + }, + {"rule_id": "LEN01", "rule_name": "too-long-keyword", "action": "enable"}, + {"rule_id": "NAME01", "rule_name": "not-allowed-char-in-name", "action": "disable"}, + ] + config = generate_toml_config_from_suggestions(suggestions) + assert "configure" in config["lint"] + assert "select" in config["lint"] + assert "ignore" in config["lint"] + + def test_set_action_common_section(self): + """Should handle set action for common section options.""" + suggestions = [ + { + "action": "set", + "parameter": "cache_dir", + "value": "/tmp/robocop-cache", # noqa: S108 + "section": "common", + } + ] + config = generate_toml_config_from_suggestions(suggestions) + assert "common" in config + assert config["common"]["cache_dir"] == "/tmp/robocop-cache" # noqa: S108 + + def test_set_action_format_section(self): + """Should handle set action for format section options.""" + suggestions = [ + { + "action": "set", + "parameter": "space_count", + "value": "2", + "section": "format", + } + ] + config = generate_toml_config_from_suggestions(suggestions) + assert "format" in config + assert config["format"]["space_count"] == 2 # Should be converted to int + + def test_mixed_sections(self): + """Should handle suggestions targeting different sections.""" + suggestions = [ + { + "action": "set", + "parameter": "cache_dir", + "value": "/tmp/cache", # noqa: S108 + "section": "common", + }, + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + "section": "lint", + }, + { + "action": "set", + "parameter": "space_count", + "value": "4", + "section": "format", + }, + ] + config = generate_toml_config_from_suggestions(suggestions) + assert config["common"]["cache_dir"] == "/tmp/cache" # noqa: S108 + assert "line-too-long.line_length=140" in config["lint"]["configure"] + assert config["format"]["space_count"] == 4 + + +class TestTomlHandler: + """Tests for TOML handler utilities.""" + + def test_read_nonexistent_file(self): + """Should return empty dict for nonexistent file.""" + result = read_toml_file(Path("/nonexistent/file.toml")) + assert result == {} + + def test_read_write_roundtrip(self): + """Should preserve data through read/write cycle.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "test.toml" + config = {"tool": {"robocop": {"lint": {"configure": ["rule.param=value"]}}}} + + write_toml_file(path, config) + result = read_toml_file(path) + + assert result == config + + def test_merge_new_config(self): + """Should merge new config into empty config.""" + existing = {} + new_lint = {"configure": ["rule.param=value"]} + + merged = merge_robocop_config(existing, new_lint) + + assert merged["tool"]["robocop"]["lint"]["configure"] == ["rule.param=value"] + + def test_merge_with_existing_config(self): + """Should merge new config with existing config.""" + existing = {"tool": {"robocop": {"lint": {"configure": ["old.param=1"]}}}} + new_lint = {"configure": ["new.param=2"]} + + merged = merge_robocop_config(existing, new_lint) + + assert "old.param=1" in merged["tool"]["robocop"]["lint"]["configure"] + assert "new.param=2" in merged["tool"]["robocop"]["lint"]["configure"] + + def test_merge_replaces_same_param(self): + """Should replace configure entries for same rule.param.""" + existing = {"tool": {"robocop": {"lint": {"configure": ["rule.param=old"]}}}} + new_lint = {"configure": ["rule.param=new"]} + + merged = merge_robocop_config(existing, new_lint) + configure = merged["tool"]["robocop"]["lint"]["configure"] + + assert len(configure) == 1 + assert configure[0] == "rule.param=new" + + def test_merge_preserves_other_sections(self): + """Should preserve other sections in existing config.""" + existing = { + "project": {"name": "test"}, + "tool": {"other": {"key": "value"}}, + } + new_lint = {"configure": ["rule.param=value"]} + + merged = merge_robocop_config(existing, new_lint) + + assert merged["project"]["name"] == "test" + assert merged["tool"]["other"]["key"] == "value" + + def test_generate_diff(self): + """Should generate unified diff.""" + before = "line1\nline2\n" + after = "line1\nmodified\n" + + diff = generate_diff(before, after) + + assert diff is not None + assert "-line2" in diff + assert "+modified" in diff + + def test_generate_diff_no_changes(self): + """Should return None when no changes.""" + content = "line1\nline2\n" + diff = generate_diff(content, content) + assert diff is None + + def test_extract_lint_section_string(self): + """Should generate TOML string for lint section.""" + lint_config = {"configure": ["rule.param=value"]} + + result = extract_lint_section_string(lint_config) + + assert "[tool.robocop.lint]" in result + assert "configure" in result + + +class TestTomlCommentPreservation: + """Tests for TOML comment preservation using tomlkit.""" + + def test_preserves_comments_in_existing_file(self): + """Should preserve comments when modifying existing TOML file.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + + # Create file with comments + original_content = """# Project configuration file +[project] +name = "my-project" # inline comment + +# Tool configurations below +[tool.ruff] +line-length = 100 # ruff line length setting +""" + path.write_text(original_content) + + # Apply robocop config + toml_config = '[tool.robocop.lint]\nconfigure = ["line-too-long.line_length=140"]' + result = _apply_config_impl(toml_config, str(path)) + + assert result.success is True + + # Read back and verify comments are preserved + final_content = path.read_text() + + # Check that original comments are preserved + assert "# Project configuration file" in final_content + assert "# inline comment" in final_content + assert "# Tool configurations below" in final_content + assert "# ruff line length setting" in final_content + + # Check that robocop config was added + assert "[tool.robocop.lint]" in final_content or "tool.robocop" in final_content + assert "line-too-long.line_length=140" in final_content + + def test_preserves_other_tool_sections(self): + """Should preserve other tool sections with their comments.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + + original_content = """[project] +name = "test" + +# Ruff configuration +[tool.ruff] +line-length = 100 +select = ["E", "F"] + +# Pytest configuration +[tool.pytest.ini_options] +minversion = "6.0" +""" + path.write_text(original_content) + + toml_config = '[tool.robocop.lint]\nignore = ["DOC01"]' + result = _apply_config_impl(toml_config, str(path)) + + assert result.success is True + final_content = path.read_text() + + # Comments preserved + assert "# Ruff configuration" in final_content + assert "# Pytest configuration" in final_content + + # Other tool sections preserved + assert "[tool.ruff]" in final_content + assert "[tool.pytest.ini_options]" in final_content + assert "line-length = 100" in final_content + + def test_merge_updates_existing_robocop_section_preserving_comments(self): + """Should update existing robocop section while preserving surrounding comments.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + + original_content = """[project] +name = "test" + +# Robocop linting rules +[tool.robocop.lint] +ignore = ["DOC01"] # ignore doc rules + +# More config below +[tool.other] +key = "value" +""" + path.write_text(original_content) + + toml_config = '[tool.robocop.lint]\nconfigure = ["line-too-long.line_length=140"]' + result = _apply_config_impl(toml_config, str(path)) + + assert result.success is True + final_content = path.read_text() + + # Comments preserved + assert "# Robocop linting rules" in final_content + assert "# More config below" in final_content + + # Original config preserved and new config added + assert "DOC01" in final_content + assert "line-too-long.line_length=140" in final_content + + def test_robocop_toml_preserves_comments(self): + """Should preserve comments in robocop.toml files.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "robocop.toml" + + original_content = """# Robocop configuration +# Main settings + +[lint] +ignore = ["DOC01"] # skip doc checks + +# Format settings below +[format] +line_length = 120 +""" + path.write_text(original_content) + + toml_config = '[lint]\nconfigure = ["line-too-long.line_length=140"]' + result = _apply_config_impl(toml_config, str(path)) + + assert result.success is True + final_content = path.read_text() + + # Comments preserved + assert "# Robocop configuration" in final_content + assert "# Main settings" in final_content + assert "# skip doc checks" in final_content + assert "# Format settings below" in final_content + + +class TestMinimalDiffBehavior: + """Tests for minimal diff behavior - append at end vs merge in place.""" + + def test_new_config_appended_at_end_of_file(self): + """When no robocop config exists, new config should be appended at end.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + + # Create file with other config but no robocop + original_content = """[project] +name = "test" + +[tool.ruff] +line-length = 100 +""" + path.write_text(original_content) + + toml_config = '[tool.robocop.lint]\nignore = ["DOC01"]' + result = _apply_config_impl(toml_config, str(path)) + + assert result.success is True + final_content = path.read_text() + + # Original content should be preserved exactly at the start + assert final_content.startswith('[project]\nname = "test"') + + # Robocop config should be at the end + assert final_content.endswith('ignore = ["DOC01"]\n') or "DOC01" in final_content + + # [tool.ruff] should come BEFORE [tool.robocop] + ruff_pos = final_content.find("[tool.ruff]") + robocop_pos = final_content.find("[tool.robocop") + assert ruff_pos < robocop_pos, "Robocop config should be appended after existing content" + + def test_existing_robocop_config_updated_in_place(self): + """When robocop config exists, it should be updated in place, not moved.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + + # Create file with robocop config in the middle + original_content = """[project] +name = "test" + +[tool.robocop.lint] +ignore = ["DOC01"] + +[tool.ruff] +line-length = 100 +""" + path.write_text(original_content) + + toml_config = '[tool.robocop.lint]\nconfigure = ["line-too-long.line_length=140"]' + result = _apply_config_impl(toml_config, str(path)) + + assert result.success is True + final_content = path.read_text() + + # [tool.robocop] should still come BEFORE [tool.ruff] (in place update) + robocop_pos = final_content.find("[tool.robocop") + ruff_pos = final_content.find("[tool.ruff]") + assert robocop_pos < ruff_pos, "Robocop config should stay in its original position" + + # Both old and new config should be present + assert "DOC01" in final_content + assert "line-too-long.line_length=140" in final_content + + def test_minimal_diff_for_new_config(self): + """Diff should only show additions when adding new robocop config.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + + original_content = """[project] +name = "test" +version = "1.0.0" + +[tool.ruff] +line-length = 100 +""" + path.write_text(original_content) + + toml_config = '[tool.robocop.lint]\nignore = ["DOC01"]' + result = _apply_config_impl(toml_config, str(path)) + + assert result.success is True + assert result.diff is not None + + # Diff should only have additions (+ lines), no deletions (- lines) for existing content + diff_lines = result.diff.split("\n") + deletion_lines = [line for line in diff_lines if line.startswith("-") and not line.startswith("---")] + addition_lines = [line for line in diff_lines if line.startswith("+") and not line.startswith("+++")] + + # Should have no deletions of existing content + assert len(deletion_lines) == 0, f"Should not delete existing content: {deletion_lines}" + # Should have additions for new robocop config + assert len(addition_lines) > 0, "Should add new robocop config" + + def test_robocop_toml_new_config_at_end(self): + """For robocop.toml, new sections should be appended at end.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "robocop.toml" + + # Empty file or file without lint/format sections + original_content = """# Top-level robocop settings +threshold = "W" +""" + path.write_text(original_content) + + toml_config = '[lint]\nignore = ["DOC01"]' + result = _apply_config_impl(toml_config, str(path)) + + assert result.success is True + final_content = path.read_text() + + # Original content preserved at start + assert "# Top-level robocop settings" in final_content + # [lint] section added at end + assert "[lint]" in final_content + + +class TestParseLlmResponse: + """Tests for _parse_llm_response function.""" + + def test_parse_valid_json(self): + """Should parse valid JSON response.""" + response = '{"interpretation": "test", "suggestions": [], "warnings": []}' + parsed, error = _parse_llm_response(response) + + assert error is None + assert parsed["interpretation"] == "test" + + def test_parse_json_with_markdown(self): + """Should parse JSON wrapped in markdown code block.""" + response = '```json\n{"interpretation": "test", "suggestions": []}\n```' + parsed, error = _parse_llm_response(response) + + assert error is None + assert parsed["interpretation"] == "test" + + def test_parse_invalid_json(self): + """Should return error for invalid JSON.""" + response = "not valid json" + parsed, error = _parse_llm_response(response) + + assert parsed == {} + assert error is not None + assert "Failed to parse" in error + + +class TestValidateSuggestions: + """Tests for _validate_suggestions function.""" + + def test_valid_configure_suggestion(self): + """Should accept valid configure suggestion.""" + raw = [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + "interpretation": "Allow longer lines", + "explanation": "Increase limit", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 1 + assert result.suggestions[0].rule_id == "LEN08" + assert result.suggestions[0].action == "configure" + assert result.suggestions[0].parameter == "line_length" + assert result.suggestions[0].value == "140" + + def test_valid_disable_suggestion(self): + """Should accept valid disable suggestion.""" + raw = [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "disable", + "interpretation": "Disable line length check", + "explanation": "Not needed", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 1 + assert result.suggestions[0].action == "disable" + assert result.suggestions[0].parameter is None + assert result.suggestions[0].value is None + + def test_invalid_rule_skipped(self): + """Should skip invalid rule with warning.""" + raw = [ + { + "rule_id": "INVALID", + "rule_name": "nonexistent-rule", + "action": "configure", + "parameter": "param", + "value": "value", + "interpretation": "test", + "explanation": "test", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 0 + assert len(result.warnings) == 1 + assert "not found" in result.warnings[0] + + def test_configure_missing_param_skipped(self): + """Should skip configure action without parameter.""" + raw = [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + # Missing parameter and value + "interpretation": "test", + "explanation": "test", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 0 + assert len(result.warnings) == 1 + + def test_deprecated_rule_warning(self): + """Should add warning for deprecated rules.""" + # Find a deprecated rule from the catalog + catalog = build_rule_catalog() + deprecated_rules = [r for r in catalog if r["deprecated"]] + + if deprecated_rules: + deprecated = deprecated_rules[0] + raw = [ + { + "rule_id": deprecated["rule_id"], + "rule_name": deprecated["name"], + "action": "enable", + "interpretation": "test", + "explanation": "test", + } + ] + result = _validate_suggestions(raw) + + # Should have a deprecation warning + deprecation_warnings = [w for w in result.warnings if "deprecated" in w.lower()] + assert len(deprecation_warnings) == 1 + + +class TestParseConfigFromLlmResponse: + """Tests for parse_config_from_llm_response function.""" + + def test_successful_parse(self): + """Should successfully parse valid response.""" + response = json.dumps( + { + "interpretation": "Allow longer lines", + "suggestions": [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + "interpretation": "Allow 140 char lines", + "explanation": "Default is 120", + } + ], + "warnings": [], + } + ) + + result = parse_config_from_llm_response(response) + + assert result.success is True + assert len(result.suggestions) == 1 + assert "[tool.robocop.lint]" in result.toml_config + assert "line-too-long.line_length=140" in result.toml_config + + def test_empty_suggestions(self): + """Should handle empty suggestions.""" + response = json.dumps( + {"interpretation": "No rules match", "suggestions": [], "warnings": ["No matching rules"]} + ) + + result = parse_config_from_llm_response(response) + + assert result.success is False + assert len(result.suggestions) == 0 + + def test_invalid_json_response(self): + """Should handle invalid JSON gracefully.""" + result = parse_config_from_llm_response("not json") + + assert result.success is False + assert len(result.warnings) > 0 + + +class TestGetConfigSystemMessage: + """Tests for get_config_system_message function.""" + + def test_system_message_contains_rules(self): + """System message should contain rule information.""" + message = get_config_system_message() + + assert "Robocop" in message + assert "rules" in message.lower() + # Should contain some rule IDs + assert "LEN" in message + assert "NAME" in message + + def test_system_message_contains_instructions(self): + """System message should contain configuration instructions.""" + message = get_config_system_message() + + assert "configure" in message.lower() + assert "JSON" in message + assert "suggestions" in message + + def test_system_message_contains_examples(self): + """System message should contain configuration examples.""" + message = get_config_system_message() + + assert "Example:" in message or "example" in message.lower() + + +class TestApplyConfigImpl: + """Tests for _apply_config_impl function.""" + + def test_apply_to_new_file(self): + """Should create new file if it doesn't exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + toml_config = '[tool.robocop.lint]\nconfigure = ["line-too-long.line_length=140"]' + + result = _apply_config_impl(toml_config, str(path)) + + assert result.success is True + assert result.file_created is True + assert path.exists() + + def test_apply_to_existing_file(self): + """Should merge with existing file.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + # Create initial file + initial_content = {"project": {"name": "test"}} + write_toml_file(path, initial_content) + + toml_config = '[tool.robocop.lint]\nconfigure = ["line-too-long.line_length=140"]' + result = _apply_config_impl(toml_config, str(path)) + + assert result.success is True + assert result.file_created is False + # Should preserve existing content + final = read_toml_file(path) + assert final["project"]["name"] == "test" + assert "configure" in final["tool"]["robocop"]["lint"] + + def test_apply_generates_diff(self): + """Should generate diff showing changes.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + toml_config = '[tool.robocop.lint]\nconfigure = ["line-too-long.line_length=140"]' + + result = _apply_config_impl(toml_config, str(path)) + + # Should have a diff for new file + assert result.diff is not None or result.file_created + + def test_apply_invalid_toml(self): + """Should handle invalid TOML gracefully.""" + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + + result = _apply_config_impl("invalid toml {{{", str(path)) + + assert result.success is False + assert result.validation_error is not None + + +class TestValidateSuggestionsSetAction: + """Tests for _validate_suggestions with 'set' action.""" + + def test_valid_set_action_common_section(self): + """Should accept valid set action for common section.""" + raw = [ + { + "action": "set", + "parameter": "cache_dir", + "value": "/tmp/cache", # noqa: S108 + "section": "common", + "interpretation": "Set cache directory", + "explanation": "Custom cache location", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 1 + assert result.suggestions[0].action == "set" + assert result.suggestions[0].parameter == "cache_dir" + assert result.suggestions[0].value == "/tmp/cache" # noqa: S108 + assert result.suggestions[0].section == "common" + assert result.suggestions[0].rule_id is None + + def test_valid_set_action_format_section(self): + """Should accept valid set action for format section.""" + raw = [ + { + "action": "set", + "parameter": "space_count", + "value": "2", + "section": "format", + "interpretation": "Set indentation", + "explanation": "Use 2 spaces", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 1 + assert result.suggestions[0].section == "format" + + def test_set_action_missing_value_skipped(self): + """Should skip set action without value.""" + raw = [ + { + "action": "set", + "parameter": "cache_dir", + "section": "common", + "interpretation": "Missing value", + "explanation": "Should fail", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 0 + assert len(result.warnings) == 1 + assert "missing" in result.warnings[0].lower() + + def test_set_action_missing_parameter_skipped(self): + """Should skip set action without parameter.""" + raw = [ + { + "action": "set", + "value": "/tmp/cache", # noqa: S108 + "section": "common", + "interpretation": "Missing parameter", + "explanation": "Should fail", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 0 + assert len(result.warnings) == 1 + + def test_invalid_section_defaults_to_lint(self): + """Should default to lint section and warn for invalid section.""" + raw = [ + { + "action": "set", + "parameter": "some_param", + "value": "some_value", + "section": "invalid_section", + "interpretation": "Invalid section", + "explanation": "Should warn", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 1 + assert result.suggestions[0].section == "lint" # Defaulted + assert len(result.warnings) == 1 + assert "invalid" in result.warnings[0].lower() + + +class TestBuildConfigOptionsCatalog: + """Tests for build_config_options_catalog function.""" + + def test_catalog_has_all_sections(self): + """Catalog should have common, lint, and format sections.""" + catalog = build_config_options_catalog() + + assert "common" in catalog + assert "lint" in catalog + assert "format" in catalog + + def test_common_section_has_options(self): + """Common section should have config options.""" + catalog = build_config_options_catalog() + common = catalog["common"] + + # Should have some options + assert len(common) > 0 + # Check structure + for opt in common: + assert "name" in opt + assert "type" in opt + assert "default" in opt + + def test_format_section_has_options(self): + """Format section should have config options.""" + catalog = build_config_options_catalog() + fmt = catalog["format"] + + # Should have some format options + assert len(fmt) > 0 + + +class TestIntegration: + """Integration tests for the complete workflow.""" + + def test_full_workflow(self): + """Test complete workflow from LLM response to file application.""" + # Step 1: Get system message (would be used by host LLM) + system_message = get_config_system_message() + assert len(system_message) > 1000 + + # Step 2: Parse LLM response + llm_response = json.dumps( + { + "interpretation": "Configure line length and disable naming rule", + "suggestions": [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + "interpretation": "Allow 140 char lines", + "explanation": "Increase from default 120", + }, + { + "rule_id": "NAME01", + "rule_name": "not-allowed-char-in-name", + "action": "disable", + "interpretation": "Disable naming check", + "explanation": "Project uses dots in names", + }, + ], + "warnings": [], + } + ) + + parse_result = parse_config_from_llm_response(llm_response) + assert parse_result.success is True + assert len(parse_result.suggestions) == 2 + + # Step 3: Apply to file + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + + apply_result = _apply_config_impl(parse_result.toml_config, str(path)) + + assert apply_result.success is True + + # Verify file contents + final_config = read_toml_file(path) + lint_section = final_config["tool"]["robocop"]["lint"] + + assert "line-too-long.line_length=140" in lint_section["configure"] + assert "NAME01" in lint_section["ignore"] + + def test_workflow_with_set_action(self): + """Test workflow with set action for config options.""" + llm_response = json.dumps( + { + "interpretation": "Set cache directory and indentation", + "suggestions": [ + { + "action": "set", + "parameter": "cache_dir", + "value": "/custom/cache", + "section": "common", + "interpretation": "Custom cache path", + "explanation": "Store cache in custom location", + }, + { + "action": "set", + "parameter": "space_count", + "value": "2", + "section": "format", + "interpretation": "2-space indent", + "explanation": "Use 2 spaces for indentation", + }, + ], + "warnings": [], + } + ) + + parse_result = parse_config_from_llm_response(llm_response) + assert parse_result.success is True + assert len(parse_result.suggestions) == 2 + + # Verify TOML contains both sections + toml_config = parse_result.toml_config + assert "cache_dir" in toml_config + assert "space_count" in toml_config + + def test_workflow_mixed_actions(self): + """Test workflow with both rule config and set actions.""" + llm_response = json.dumps( + { + "interpretation": "Configure rules and options", + "suggestions": [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + "section": "lint", + "interpretation": "Longer lines", + "explanation": "Allow 140 chars", + }, + { + "action": "set", + "parameter": "cache_dir", + "value": "/tmp/cache", # noqa: S108 + "section": "common", + "interpretation": "Cache dir", + "explanation": "Custom cache", + }, + ], + "warnings": [], + } + ) + + parse_result = parse_config_from_llm_response(llm_response) + assert parse_result.success is True + assert len(parse_result.suggestions) == 2 + + # Apply to file + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "pyproject.toml" + + apply_result = _apply_config_impl(parse_result.toml_config, str(path)) + + assert apply_result.success is True + + # Verify file contains both sections + final_config = read_toml_file(path) + assert "cache_dir" in final_config["tool"]["robocop"] + assert "lint" in final_config["tool"]["robocop"] + + +class TestEdgeCases: + """Tests for edge cases and corner scenarios.""" + + def test_suggestion_without_section_defaults_to_lint(self): + """Suggestion without explicit section should default to lint.""" + raw = [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + # No section field - should default to "lint" + "interpretation": "Allow longer lines", + "explanation": "Default to lint", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 1 + assert result.suggestions[0].section == "lint" + + def test_empty_suggestions_array(self): + """Response with empty suggestions array should fail.""" + response = json.dumps( + { + "interpretation": "User wants nothing", + "suggestions": [], + "warnings": [], + } + ) + + result = parse_config_from_llm_response(response) + + assert result.success is False + assert len(result.suggestions) == 0 + + def test_configure_with_empty_value(self): + """Configure action with empty value string should be skipped.""" + raw = [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "", # Empty value + "interpretation": "Empty value", + "explanation": "Should fail", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 0 + assert len(result.warnings) > 0 + + def test_duplicate_configure_entries_last_wins(self): + """Multiple configure entries for same rule.param should result in one entry.""" + suggestions = [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "100", + "section": "lint", + }, + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "200", # This should win + "section": "lint", + }, + ] + config = generate_toml_config_from_suggestions(suggestions) + + # Should have both entries (generation doesn't dedupe, but that's fine) + configure = config["lint"]["configure"] + assert "line-too-long.line_length=100" in configure + assert "line-too-long.line_length=200" in configure + + def test_boolean_value_conversion(self): + """Set action with boolean-like strings should convert properly.""" + suggestions = [ + { + "action": "set", + "parameter": "verbose", + "value": "true", + "section": "common", + }, + { + "action": "set", + "parameter": "silent", + "value": "false", + "section": "common", + }, + ] + config = generate_toml_config_from_suggestions(suggestions) + + # Boolean conversion: "true" -> True, "false" -> False + assert config["common"]["verbose"] is True + assert config["common"]["silent"] is False + + def test_numeric_value_conversion(self): + """Set action with numeric strings should convert to numbers.""" + suggestions = [ + { + "action": "set", + "parameter": "space_count", + "value": "4", + "section": "format", + }, + { + "action": "set", + "parameter": "line_length", + "value": "120", + "section": "format", + }, + ] + config = generate_toml_config_from_suggestions(suggestions) + + assert config["format"]["space_count"] == 4 + assert config["format"]["line_length"] == 120 + assert isinstance(config["format"]["space_count"], int) + + def test_rule_lookup_case_insensitive_name(self): + """Rule lookup should work with case variations.""" + # Lowercase + rule1 = get_rule_by_name_or_id("line-too-long") + assert rule1 is not None + + # The exact case should work + assert rule1.name == "line-too-long" + + def test_section_field_respected_in_response(self): + """Section field in LLM response should be respected for rule actions.""" + response = json.dumps( + { + "interpretation": "Configure rule in lint section", + "suggestions": [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + "section": "lint", # Explicit section + "interpretation": "Allow longer lines", + "explanation": "In lint section", + } + ], + "warnings": [], + } + ) + + result = parse_config_from_llm_response(response) + + assert result.success is True + assert result.suggestions[0].section == "lint" + assert "[tool.robocop.lint]" in result.toml_config + + @pytest.mark.skipif(sys.platform == "win32", reason="Unix-style permissions don't work on Windows") + def test_apply_to_read_only_directory(self): + """Should handle permission errors gracefully.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a read-only directory (Unix only) + readonly_dir = Path(tmpdir) / "readonly" + readonly_dir.mkdir() + + # Make it read-only + original_mode = readonly_dir.stat().st_mode + try: + os.chmod(readonly_dir, stat.S_IRUSR | stat.S_IXUSR) + + path = readonly_dir / "test.toml" + toml_config = '[tool.robocop.lint]\nconfigure = ["rule.param=value"]' + + result = _apply_config_impl(toml_config, str(path)) + + # Should fail with permission error + assert result.success is False + assert result.validation_error is not None + assert "Permission" in result.validation_error or "denied" in result.validation_error.lower() + finally: + # Restore permissions for cleanup + os.chmod(readonly_dir, original_mode) + + def test_enable_action_for_disabled_rule_still_works(self): + """Enable action should work for rules that are disabled by default.""" + # Find a disabled rule + catalog = build_rule_catalog() + disabled_rules = [r for r in catalog if not r["enabled"]] + + if disabled_rules: + disabled = disabled_rules[0] + raw = [ + { + "rule_id": disabled["rule_id"], + "rule_name": disabled["name"], + "action": "enable", + "interpretation": "Enable disabled rule", + "explanation": "Turn on", + } + ] + result = _validate_suggestions(raw) + + assert len(result.suggestions) == 1 + assert result.suggestions[0].action == "enable" + # Should not have warnings about the rule being disabled + disabled_warnings = [w for w in result.warnings if "disabled" in w.lower()] + assert len(disabled_warnings) == 0 + + def test_negative_integer_value(self): + """Should handle negative integers in set action.""" + suggestions = [ + { + "action": "set", + "parameter": "threshold", + "value": "-1", + "section": "lint", + } + ] + config = generate_toml_config_from_suggestions(suggestions) + + assert config["lint"]["threshold"] == -1 + assert isinstance(config["lint"]["threshold"], int) + + def test_string_value_preserved(self): + """Non-numeric string values should remain strings.""" + suggestions = [ + { + "action": "set", + "parameter": "cache_dir", + "value": "/path/to/cache", + "section": "common", + }, + { + "action": "set", + "parameter": "language", + "value": "en", + "section": "common", + }, + ] + config = generate_toml_config_from_suggestions(suggestions) + + assert config["common"]["cache_dir"] == "/path/to/cache" + assert config["common"]["language"] == "en" + assert isinstance(config["common"]["cache_dir"], str) + + def test_warnings_from_llm_preserved(self): + """Warnings from LLM response should be preserved in result.""" + response = json.dumps( + { + "interpretation": "User request", + "suggestions": [ + { + "rule_id": "LEN08", + "rule_name": "line-too-long", + "action": "configure", + "parameter": "line_length", + "value": "140", + "interpretation": "Allow longer lines", + "explanation": "Increase limit", + } + ], + "warnings": ["This might affect readability", "Consider your team's preferences"], + } + ) + + result = parse_config_from_llm_response(response) + + assert result.success is True + assert "This might affect readability" in result.warnings + assert "Consider your team's preferences" in result.warnings + + def test_special_characters_in_path_value(self): + """Values with special characters should be handled.""" + suggestions = [ + { + "action": "set", + "parameter": "cache_dir", + "value": "/path/with spaces/and-dashes/under_scores", + "section": "common", + } + ] + config = generate_toml_config_from_suggestions(suggestions) + + assert config["common"]["cache_dir"] == "/path/with spaces/and-dashes/under_scores" + + def test_system_message_has_cli_only_warning(self): + """System message should warn about CLI-only options.""" + message = get_config_system_message() + + # Should mention CLI-only options + assert "CLI" in message or "command-line" in message.lower() + # Should mention specific CLI-only options + assert "--ignore-git-dir" in message or "ignore-git-dir" in message + + def test_format_section_options_in_system_message(self): + """System message should include format section options.""" + message = get_config_system_message() + + # Should mention format-related options + assert "format" in message.lower() + assert "space_count" in message or "indent" in message.lower() diff --git a/uv.lock b/uv.lock index ce985c2ba..befdd81cc 100644 --- a/uv.lock +++ b/uv.lock @@ -2307,6 +2307,7 @@ dependencies = [ [package.optional-dependencies] mcp = [ { name = "fastmcp", marker = "python_full_version >= '3.10'" }, + { name = "tomlkit", marker = "python_full_version >= '3.10'" }, ] [package.dev-dependencies] @@ -2342,6 +2343,7 @@ requires-dist = [ { name = "robotframework", specifier = ">=4.0,<7.5" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = "==2.2.1" }, { name = "tomli-w", specifier = ">=1.0" }, + { name = "tomlkit", marker = "python_full_version >= '3.10' and extra == 'mcp'", specifier = ">=0.13.0" }, { name = "typer-slim", specifier = ">=0.12.5" }, ] provides-extras = ["mcp"] @@ -2663,6 +2665,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + [[package]] name = "typer" version = "0.20.1"