From 3c901bebb0f48ea4f421f94213822e1bfd96b413 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sun, 10 Aug 2025 22:51:06 -0300 Subject: [PATCH] feat(generate): add --diff to show unified diffs in dry-run/console modes; include per-file action summary (closes #93) --- struct_module/commands/generate.py | 48 ++++++++++++++++++++++++------ tests/test_commands_more.py | 38 +++++++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/struct_module/commands/generate.py b/struct_module/commands/generate.py index e78b83d..721df2c 100644 --- a/struct_module/commands/generate.py +++ b/struct_module/commands/generate.py @@ -18,6 +18,7 @@ def __init__(self, parser): parser.add_argument('-s', '--structures-path', type=str, help='Path to structure definitions') parser.add_argument('-n', '--input-store', type=str, help='Path to the input store', default='/tmp/struct/input.json') parser.add_argument('-d', '--dry-run', action='store_true', help='Perform a dry run without creating any files or directories') + parser.add_argument('--diff', action='store_true', help='Show unified diffs for files that would change during dry-run or console output') parser.add_argument('-v', '--vars', type=str, help='Template variables in the format KEY1=value1,KEY2=value2') parser.add_argument('-b', '--backup', type=str, help='Path to the backup folder') parser.add_argument('-f', '--file-strategy', type=str, choices=['overwrite', 'skip', 'append', 'rename', 'backup'], default='overwrite', help='Strategy for handling existing files').completer = file_strategy_completer @@ -189,18 +190,47 @@ def _create_structure(self, args, mappings=None): ) file_item.apply_template_variables(template_vars) - # Output mode logic + # Output mode logic with diff support if hasattr(args, 'output') and args.output == 'console': - # Print the file path and content to the console instead of creating the file print(f"=== {file_path_to_create} ===") - print(file_item.content) + if args.diff and existing_content is not None: + import difflib + new_content = file_item.content if file_item.content.endswith("\n") else file_item.content + "\n" + old_content = existing_content if existing_content.endswith("\n") else existing_content + "\n" + diff = difflib.unified_diff( + old_content.splitlines(keepends=True), + new_content.splitlines(keepends=True), + fromfile=f"a/{file_path_to_create}", + tofile=f"b/{file_path_to_create}", + ) + print("".join(diff)) + else: + print(file_item.content) else: - file_item.create( - args.base_path, - args.dry_run or False, - args.backup or None, - args.file_strategy or 'overwrite' - ) + # When dry-run with --diff and files mode, print action and diff instead of writing + if args.dry_run and args.diff: + action = "create" + if existing_content is not None: + action = "update" + print(f"[DRY RUN] {action}: {file_path_to_create}") + import difflib + new_content = file_item.content if file_item.content.endswith("\n") else file_item.content + "\n" + old_content = (existing_content if existing_content is not None else "") + old_content = old_content if old_content.endswith("\n") else (old_content + ("\n" if old_content else "")) + diff = difflib.unified_diff( + old_content.splitlines(keepends=True), + new_content.splitlines(keepends=True), + fromfile=f"a/{file_path_to_create}", + tofile=f"b/{file_path_to_create}", + ) + print("".join(diff)) + else: + file_item.create( + args.base_path, + args.dry_run or False, + args.backup or None, + args.file_strategy or 'overwrite' + ) for item in config_folders: for folder, content in item.items(): diff --git a/tests/test_commands_more.py b/tests/test_commands_more.py index 20852f7..f13151b 100644 --- a/tests/test_commands_more.py +++ b/tests/test_commands_more.py @@ -50,6 +50,44 @@ def test_generate_creates_base_path_and_console_output(parser, tmp_path): mock_makedirs.assert_called() # base path created +def test_generate_dry_run_diff_shows_unified_diff(parser, tmp_path): + command = GenerateCommand(parser) + args = parser.parse_args(['struct-x', str(tmp_path / 'base')]) + + # Minimal config to trigger one file update + config = {'files': [{'hello.txt': 'Hello world'}], 'folders': []} + + # Existing file with different content + base_dir = tmp_path / 'base' + base_dir.mkdir(parents=True, exist_ok=True) + (base_dir / 'hello.txt').write_text('Hello old\n') + + store_dir = tmp_path / 'store' + store_dir.mkdir(parents=True, exist_ok=True) + with open(store_dir / 'input.json', 'w') as fh: + fh.write('{}') + + with patch.object(command, '_load_yaml_config', return_value=config), \ + patch('builtins.print') as mock_print: + args.output = 'file' + args.input_store = str(store_dir / 'input.json') + args.dry_run = True + args.diff = True + args.vars = None + args.backup = None + args.file_strategy = 'overwrite' + args.global_system_prompt = None + args.structures_path = None + args.non_interactive = True + + command.execute(args) + + # Should have printed a DRY RUN action and diff + printed = ''.join(call.args[0] for call in mock_print.call_args_list) + assert '[DRY RUN] update' in printed + assert '--- a' in printed and '+++ b' in printed + + def test_generate_pre_hook_failure_aborts(parser, tmp_path): command = GenerateCommand(parser) args = parser.parse_args(['struct-x', str(tmp_path)])