diff --git a/src/dda/cli/validate/__init__.py b/src/dda/cli/validate/__init__.py new file mode 100644 index 00000000..d6920370 --- /dev/null +++ b/src/dda/cli/validate/__init__.py @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from dda.cli.base import dynamic_group + + +@dynamic_group( + short_help="Validate tools and utilities", +) +def cmd() -> None: + """ + Validate tools and utilities for development workflow. + """ diff --git a/src/dda/cli/validate/ai_rules/__init__.py b/src/dda/cli/validate/ai_rules/__init__.py new file mode 100644 index 00000000..cc021e41 --- /dev/null +++ b/src/dda/cli/validate/ai_rules/__init__.py @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from dda.cli.base import dynamic_command, pass_app +from dda.utils.diff import pretty_diff +from dda.utils.fs import Path + +if TYPE_CHECKING: + from dda.cli.application import Application + +CURSOR_RULES_DIR = Path(".cursor/rules") +TARGETS_FILES = [Path("CLAUDE.md")] + + +@dynamic_command(short_help="Validate AI rules are coherent between all the coding agent config files") +@click.option( + "--fix", + "-k", + "should_fix", + is_flag=True, + help="Fix the rules if they are not coherent.", +) +@pass_app +def cmd(app: Application, *, should_fix: bool) -> None: + cursor_rules_dir = CURSOR_RULES_DIR + targets_files = TARGETS_FILES + + # Find all rule files + rule_files = get_rule_files(cursor_rules_dir) + + if not rule_files: + app.display_warning(f"No rule files found in {cursor_rules_dir}") + app.display_info("Add your cursor rules files to the .cursor/rules directory and run this command again.") + return + + app.display_debug(f"Found {len(rule_files)} rule files") + + unsynced_targets = [] + for target_file in targets_files: + new_content = generate_content(rule_files, target_file) + old_content = "" + if target_file.exists() and target_file.is_file(): + with open(target_file, encoding="utf-8") as f: + old_content = f.read() + diff = pretty_diff(old_content, new_content) + if not diff: + continue + app.display_info(f"Target file {target_file} is not in sync") + app.display_info(diff) + if should_fix: + with open(target_file, "w", encoding="utf-8") as f: + f.write(new_content) + app.display_success(f"Successfully fixed {target_file}") + else: + unsynced_targets.append(str(target_file)) + if unsynced_targets: + app.display_error(f"The following targets are not in sync: {', '.join(unsynced_targets)}") + app.abort() + app.display_success("All targets are in sync") + + +def get_rule_files(cursor_rules_dir: Path) -> list[Path]: + """Find all rule files in cursor rules directory (recursively), excluding personal rules.""" + return sorted(rule for rule in cursor_rules_dir.glob("**/*.mdc") if "personal" not in rule.parts) + + +def generate_content(rule_files: list[Path], target_file: Path) -> str: + """Generate the content to be written to the target file.""" + # Add header with warning and instructions + content_parts = [ + """ + +# AI Assistant Rules + +This file contains concatenated rules from the `.cursor/rules` folder to help Claude understand the project context and coding standards. + +## How to Read Metadata in Cursor Rules + +Cursor rules contains the following metadata at the begnning of the file between `---` lines. + - alwaysApply: boolean, if true, the rule will be applied to all files + - globs: array of strings, glob patterns specifying which files to apply the rule to + - description: string, a description of the rule +--- + +""" + ] + # Process each rule file + content_parts.extend([ + f"@{rule_file.absolute().relative_to(target_file.parent.absolute(), walk_up=True)}" for rule_file in rule_files + ]) + + # Add CLAUDE_PERSONAL.md reference + content_parts.append(""" +# Personal rules +@CLAUDE_PERSONAL.md""") + + combined_content = "\n".join(content_parts) + + # Remove trailing separators + return combined_content.rstrip("\n-").rstrip() + "\n" diff --git a/src/dda/utils/diff.py b/src/dda/utils/diff.py new file mode 100644 index 00000000..b588c9b8 --- /dev/null +++ b/src/dda/utils/diff.py @@ -0,0 +1,18 @@ +import difflib + + +def pretty_diff(string1: str, string2: str) -> str: + lines1 = string1.splitlines() + lines2 = string2.splitlines() + + diff = difflib.unified_diff(lines1, lines2, lineterm="") + + result = [] + for line in diff: + if line.startswith("-"): + result.append(f"\033[31m{line}\033[0m") # Red for removals + elif line.startswith("+"): + result.append(f"\033[32m{line}\033[0m") # Green for additions + else: + result.append(line) + return "\n".join(result) diff --git a/tests/cli/validate/__init__.py b/tests/cli/validate/__init__.py new file mode 100644 index 00000000..79ca6026 --- /dev/null +++ b/tests/cli/validate/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT diff --git a/tests/cli/validate/fixtures/ai_rules/.gitkeep b/tests/cli/validate/fixtures/ai_rules/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/validate/fixtures/ai_rules/no_cursor_rules/.gitkeep b/tests/cli/validate/fixtures/ai_rules/no_cursor_rules/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/validate/fixtures/ai_rules/out_of_sync/.cursor/rules/test-rule.mdc b/tests/cli/validate/fixtures/ai_rules/out_of_sync/.cursor/rules/test-rule.mdc new file mode 100644 index 00000000..978b17c7 --- /dev/null +++ b/tests/cli/validate/fixtures/ai_rules/out_of_sync/.cursor/rules/test-rule.mdc @@ -0,0 +1 @@ +This is a test rule. diff --git a/tests/cli/validate/fixtures/ai_rules/out_of_sync/CLAUDE.md b/tests/cli/validate/fixtures/ai_rules/out_of_sync/CLAUDE.md new file mode 100644 index 00000000..d29ca05f --- /dev/null +++ b/tests/cli/validate/fixtures/ai_rules/out_of_sync/CLAUDE.md @@ -0,0 +1 @@ +Old content diff --git a/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/coding-standards.mdc b/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/coding-standards.mdc new file mode 100644 index 00000000..be07ea52 --- /dev/null +++ b/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/coding-standards.mdc @@ -0,0 +1 @@ +Use TypeScript for all frontend code. diff --git a/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/imhere.txt b/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/imhere.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/nested/my-nested-rule.mdc b/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/nested/my-nested-rule.mdc new file mode 100644 index 00000000..3dca9096 --- /dev/null +++ b/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/nested/my-nested-rule.mdc @@ -0,0 +1,3 @@ +--- +alwaysApply: true +--- diff --git a/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/personal/mypersonal-rules.mdc b/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/personal/mypersonal-rules.mdc new file mode 100644 index 00000000..3dca9096 --- /dev/null +++ b/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/personal/mypersonal-rules.mdc @@ -0,0 +1,3 @@ +--- +alwaysApply: true +--- diff --git a/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/security.mdc b/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/security.mdc new file mode 100644 index 00000000..3f7c0ad9 --- /dev/null +++ b/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/security.mdc @@ -0,0 +1 @@ +Always validate input parameters. diff --git a/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/testing.mdc b/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/testing.mdc new file mode 100644 index 00000000..ee2a8211 --- /dev/null +++ b/tests/cli/validate/fixtures/ai_rules/simple_rules_no_target/.cursor/rules/testing.mdc @@ -0,0 +1 @@ +Write unit tests for all functions. diff --git a/tests/cli/validate/test_ai_rules.py b/tests/cli/validate/test_ai_rules.py new file mode 100644 index 00000000..b90e68a9 --- /dev/null +++ b/tests/cli/validate/test_ai_rules.py @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import os +import shutil +from typing import TYPE_CHECKING + +import pytest + +from dda.utils.fs import Path + +if TYPE_CHECKING: + from collections.abc import Callable + + from tests.conftest import CliRunner + + +@pytest.fixture(name="use_temp_fixture_folder") +def fixt_use_temp_folder(temp_dir: Path) -> Callable[[str], Path]: + def _use_temp_folder(folder_name: str) -> Path: + shutil.copytree(Path(__file__).parent / "fixtures" / "ai_rules" / folder_name, temp_dir / folder_name) + return Path(temp_dir) / folder_name + + return _use_temp_folder + + +def test_validate_rule_files_no_target_file( + dda: CliRunner, + use_temp_fixture_folder: Callable[[str], Path], +) -> None: + """Test validation with multiple rule files.""" + + path = use_temp_fixture_folder("simple_rules_no_target") + with path.as_cwd(): + result = dda("validate", "ai-rules") + + result.check_exit_code(exit_code=1) + + +def test_validate_with_fix_flag( + dda: CliRunner, + use_temp_fixture_folder: Callable[[str], Path], +) -> None: + """Test validation with fix flag when files are out of sync.""" + path = use_temp_fixture_folder("simple_rules_no_target") + with path.as_cwd(): + result = dda("validate", "ai-rules", "--fix") + + result.check_exit_code(exit_code=0) + assert (path / "CLAUDE.md").exists() + content = (path / "CLAUDE.md").read_text(encoding="utf-8") + assert f"@{os.path.join('.cursor', 'rules', 'coding-standards.mdc')}" in content + assert f"@{os.path.join('.cursor', 'rules', 'security.mdc')}" in content + assert f"@{os.path.join('.cursor', 'rules', 'testing.mdc')}" in content + assert "imhere.txt" not in content + assert f"@{os.path.join('.cursor', 'rules', 'personal', 'my-rule.mdc')}" not in content + assert f"@{os.path.join('.cursor', 'rules', 'nested', 'my-nested-rule.mdc')}" in content + assert "@CLAUDE_PERSONAL.md" in content + + +def test_validate_no_cursor_rules_directory( + dda: CliRunner, + use_temp_fixture_folder: Callable[[str], Path], +) -> None: + """Test validation when cursor rules directory doesn't exist.""" + path = use_temp_fixture_folder("no_cursor_rules") + with path.as_cwd(): + result = dda("validate", "ai-rules") + + result.check_exit_code(exit_code=0) + # Should not create target file if no rules directory + target_file = path / "CLAUDE.md" + assert not target_file.exists() + + +def test_validate_in_sync( + dda: CliRunner, + use_temp_fixture_folder: Callable[[str], Path], +) -> None: + """Test validation when files are already in sync.""" + path = use_temp_fixture_folder("simple_rules_no_target") + + with path.as_cwd(): + # First fix the files + result = dda("validate", "ai-rules", "--fix") + result.check_exit_code(exit_code=0) + + # Then validate without fix + result = dda("validate", "ai-rules") + result.check_exit_code(exit_code=0) + + +def test_validate_out_of_sync( + dda: CliRunner, + use_temp_fixture_folder: Callable[[str], Path], +) -> None: + """Test validation when files are out of sync.""" + path = use_temp_fixture_folder("out_of_sync") + + with path.as_cwd(): + result = dda("validate", "ai-rules") + result.check_exit_code(exit_code=1)