diff --git a/struct_module/commands/generate.py b/struct_module/commands/generate.py index 721df2c..3d9f1f1 100644 --- a/struct_module/commands/generate.py +++ b/struct_module/commands/generate.py @@ -136,7 +136,7 @@ def execute(self, args): self.logger.error("Post-hook failed.") return - def _create_structure(self, args, mappings=None): + def _create_structure(self, args, mappings=None, summary=None, print_summary=True): if isinstance(args, dict): args = argparse.Namespace(**args) this_file = os.path.dirname(os.path.realpath(__file__)) @@ -144,13 +144,27 @@ def _create_structure(self, args, mappings=None): config = self._load_yaml_config(args.structure_definition, args.structures_path) if config is None: - return + return summary if summary is not None else None template_vars = dict(item.split('=') for item in args.vars.split(',')) if args.vars else None config_structure = config.get('files', config.get('structure', [])) config_folders = config.get('folders', []) config_variables = config.get('variables', []) + # Action counters for final summary (initialize once and reuse across recursive calls) + if summary is None: + summary = { + "created": 0, + "updated": 0, + "appended": 0, + "skipped": 0, + "backed_up": 0, + "renamed": 0, + "folders": 0, + "dry_run_created": 0, + "dry_run_updated": 0, + } + for item in config_structure: self.logger.debug(f"Processing item: {item}") for name, content in item.items(): @@ -180,7 +194,7 @@ def _create_structure(self, args, mappings=None): file_path_to_create = os.path.join(args.base_path, name) existing_content = None if os.path.exists(file_path_to_create): - self.logger.warning(f"âš ī¸ File already exists: {file_path_to_create}") + self.logger.info(f"â„šī¸ Exists: {file_path_to_create}") with open(file_path_to_create, 'r') as existing_file: existing_content = existing_file.read() @@ -213,6 +227,10 @@ def _create_structure(self, args, mappings=None): if existing_content is not None: action = "update" print(f"[DRY RUN] {action}: {file_path_to_create}") + if action == "create": + summary["dry_run_created"] += 1 + else: + summary["dry_run_updated"] += 1 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 "") @@ -225,26 +243,39 @@ def _create_structure(self, args, mappings=None): ) print("".join(diff)) else: - file_item.create( + result = file_item.create( args.base_path, args.dry_run or False, args.backup or None, args.file_strategy or 'overwrite' ) + if isinstance(result, dict): + if result.get("action") == "created": + summary["created"] += 1 + elif result.get("action") == "updated": + summary["updated"] += 1 + elif result.get("action") == "appended": + summary["appended"] += 1 + elif result.get("action") == "skipped": + summary["skipped"] += 1 + if result.get("backed_up_to"): + summary["backed_up"] += 1 + if result.get("renamed_from"): + summary["renamed"] += 1 for item in config_folders: for folder, content in item.items(): folder_path = os.path.join(args.base_path, folder) if hasattr(args, 'output') and args.output == 'file': os.makedirs(folder_path, exist_ok=True) - self.logger.info(f"Created folder") - self.logger.info(f" Folder: {folder_path}") + self.logger.info(f"📁 Created folder: {folder_path}") + summary["folders"] += 1 # check if content has struct value if 'struct' in content: self.logger.info(f"Generating structure") self.logger.info(f" Folder: {folder}") - self.logger.info(f" Struct:") + self.logger.info(f" Struct(s):") if isinstance(content['struct'], list): # iterate over the list of structures for struct in content['struct']: @@ -279,13 +310,15 @@ def _create_structure(self, args, mappings=None): 'base_path': folder_path, 'structures_path': args.structures_path, 'dry_run': args.dry_run, + 'diff': getattr(args, 'diff', False), + 'output': getattr(args, 'output', 'file'), 'vars': merged_vars, 'backup': args.backup, 'file_strategy': args.file_strategy, 'global_system_prompt': args.global_system_prompt, 'input_store': args.input_store, 'non_interactive': args.non_interactive, - }) + }, mappings=mappings, summary=summary, print_summary=False) elif isinstance(content['struct'], list): for struct in content['struct']: self._create_structure({ @@ -293,12 +326,33 @@ def _create_structure(self, args, mappings=None): 'base_path': folder_path, 'structures_path': args.structures_path, 'dry_run': args.dry_run, + 'diff': getattr(args, 'diff', False), + 'output': getattr(args, 'output', 'file'), 'vars': merged_vars, 'backup': args.backup, 'file_strategy': args.file_strategy, 'global_system_prompt': args.global_system_prompt, 'input_store': args.input_store, 'non_interactive': args.non_interactive, - }) + }, mappings=mappings, summary=summary, print_summary=False) else: self.logger.warning(f"Unsupported content in folder: {folder}") + + # Final summary (only once for top-level call) + if print_summary: + self.logger.info("") + self.logger.info("Summary of actions:") + self.logger.info(f" ✅ Created: {summary['created']}") + self.logger.info(f" ✅ Updated: {summary['updated']}") + self.logger.info(f" 📝 Appended: {summary['appended']}") + self.logger.info(f" â­ī¸ Skipped: {summary['skipped']}") + self.logger.info(f" đŸ—„ī¸ Backed up: {summary['backed_up']}") + self.logger.info(f" 🔁 Renamed: {summary['renamed']}") + self.logger.info(f" 📁 Folders created: {summary['folders']}") + if args.dry_run: + self.logger.info( + f" [DRY RUN] Would create: {summary['dry_run_created']}") + self.logger.info( + f" [DRY RUN] Would update: {summary['dry_run_updated']}") + + return summary diff --git a/struct_module/file_item.py b/struct_module/file_item.py index a664908..19f5587 100644 --- a/struct_module/file_item.py +++ b/struct_module/file_item.py @@ -39,6 +39,8 @@ def __init__(self, properties): self.non_interactive, self.mappings ) + # internal flags used for reporting + self._last_action = None def _get_file_directory(self): return os.path.dirname(self.name) @@ -110,47 +112,71 @@ def create(self, base_path, dry_run=False, backup_path=None, file_strategy='over file_path = self.template_renderer.render_template(file_path, self.vars) + # default result + result = {"action": None, "path": file_path} + if self.skip: - self.logger.info(f"skip is set to true. skipping creation.") - return + self.logger.info(f"â­ī¸ Skipped (skip=true): {file_path}") + result["action"] = "skipped" + return result if dry_run: - self.logger.info(f"[DRY RUN] Would create file: {file_path} with content: \n\n{self.content}") - return + self.logger.info(f"[DRY RUN] Would create/update: {file_path}") + result["action"] = "dry_run" + return result if self.skip_if_exists and os.path.exists(file_path): - self.logger.info(f" skip_if_exists is set to true and file already exists. skipping creation.") - return + self.logger.info(f"â­ī¸ Skipped (exists and skip_if_exists=true): {file_path}") + result["action"] = "skipped" + return result # Create the directory if it does not exist os.makedirs(os.path.dirname(file_path), exist_ok=True) - if os.path.exists(file_path): + existed_before = os.path.exists(file_path) + renamed_from = None + backed_up_to = None + + if existed_before: if file_strategy == 'backup' and backup_path: backup_file_path = os.path.join(backup_path, os.path.basename(file_path)) shutil.copy2(file_path, backup_file_path) - self.logger.info(f"Backed up existing file: {file_path} to {backup_file_path}") + backed_up_to = backup_file_path + self.logger.info(f"đŸ—„ī¸ Backed up: {file_path} -> {backup_file_path}") elif file_strategy == 'skip': - self.logger.info(f"Skipped existing file: {file_path}") - return + self.logger.info(f"â­ī¸ Skipped (exists): {file_path}") + result["action"] = "skipped" + return result elif file_strategy == 'append': with open(file_path, 'a') as f: f.write(f"{self.content}\n") - self.logger.info(f"✅ Appended to existing file: {file_path}") - return + self.logger.info(f"📝 Appended: {file_path}") + result.update({"action": "appended"}) + return result elif file_strategy == 'rename': new_name = f"{file_path}.{int(time.time())}" os.rename(file_path, new_name) - self.logger.info(f"Renamed existing file: {file_path} to {new_name}") + renamed_from = new_name + self.logger.info(f"🔁 Renamed: {file_path} -> {new_name}") + # Write/overwrite the file with open(file_path, 'w') as f: f.write(f"{self.content}\n") - self.logger.info(f"✅ Created file with content") - self.logger.info(f" File path: {file_path}") - self.logger.debug(f" Content: \n\n{self.content}") + + action = "created" if not existed_before else "updated" + if action == "created": + self.logger.info(f"✅ Created: {file_path}") + else: + self.logger.info(f"✅ Updated: {file_path}") + self.logger.debug(f"Content: \n\n{self.content}") if self.permissions: os.chmod(file_path, int(self.permissions, 8)) - self.logger.info(f"🔐 Set permissions to file") - self.logger.info(f" File path: {file_path}") - self.logger.info(f" Permissions: {self.permissions}") + self.logger.info(f"🔐 Set permissions: {self.permissions} on {file_path}") + + result.update({ + "action": action, + "renamed_from": renamed_from, + "backed_up_to": backed_up_to, + }) + return result diff --git a/struct_module/mcp_server.py b/struct_module/mcp_server.py index 6e17cbd..37c071a 100644 --- a/struct_module/mcp_server.py +++ b/struct_module/mcp_server.py @@ -280,8 +280,27 @@ async def _handle_get_structure_info(self, arguments: Dict[str, Any]) -> CallToo if config.get('folders'): result_text += " 📌 Folders:\n" - for folder in config.get('folders', []): - result_text += f" - {folder}\n" + for item in config.get('folders', []): + if isinstance(item, dict): + for folder, content in item.items(): + result_text += f" - {folder}\n" + if isinstance(content, dict): + if 'struct' in content: + structs = content['struct'] + if isinstance(structs, list): + result_text += " â€ĸ struct(s):\n" + for s in structs: + result_text += f" - {s}\n" + elif isinstance(structs, str): + result_text += f" â€ĸ struct: {structs}\n" + if 'with' in content and isinstance(content['with'], dict): + result_text += " â€ĸ with:" + for k, v in content['with'].items(): + result_text += f" {k}={v}" + result_text += "\n" + else: + # Fallback if item isn't a dict + result_text += f" - {item}\n" return CallToolResult( content=[ diff --git a/tests/test_file_item_strategies.py b/tests/test_file_item_strategies.py new file mode 100644 index 0000000..731bd20 --- /dev/null +++ b/tests/test_file_item_strategies.py @@ -0,0 +1,142 @@ +import argparse +import logging +from pathlib import Path + +import pytest + +from struct_module.commands.generate import GenerateCommand + + +def _ensure_store(tmp_path): + p = tmp_path / 'input.json' + p.write_text('{}') + return str(p) + + +def _base_args(parser, tmp_path): + args = parser.parse_args(['struct-x', str(tmp_path / 'base')]) + args.output = 'file' + args.input_store = _ensure_store(tmp_path) + args.dry_run = False + args.diff = False + args.vars = None + args.backup = None + args.file_strategy = 'overwrite' + args.global_system_prompt = None + args.structures_path = None + args.non_interactive = True + return args + + +def test_backup_and_rename_strategies(tmp_path, caplog): + caplog.set_level(logging.INFO) + + parser = argparse.ArgumentParser() + command = GenerateCommand(parser) + + base_dir = tmp_path / 'base' + base_dir.mkdir(parents=True, exist_ok=True) + + # existing file to trigger backup/rename + (base_dir / 'a.txt').write_text('old') + (base_dir / 'b.txt').write_text('old-b') + + backup_dir = tmp_path / 'backup' + backup_dir.mkdir() + + config = { + 'files': [ + {'a.txt': {'content': 'new-a', 'config_variables': [], 'input_store': _ensure_store(tmp_path)}}, + {'b.txt': {'content': 'new-b', 'config_variables': [], 'input_store': _ensure_store(tmp_path)}}, + ], + 'folders': [] + } + + # First: backup strategy on a.txt + args = _base_args(parser, tmp_path) + args.backup = str(backup_dir) + args.file_strategy = 'backup' + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(command, '_load_yaml_config', lambda *_: config) + command.execute(args) + + logs = caplog.text + assert 'Backed up:' in logs + + # Then: rename strategy on b.txt + caplog.clear() + args = _base_args(parser, tmp_path) + args.file_strategy = 'rename' + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(command, '_load_yaml_config', lambda *_: config) + command.execute(args) + + logs = caplog.text + assert 'Renamed:' in logs + + +def test_skip_if_exists_path(tmp_path, caplog): + caplog.set_level(logging.INFO) + + parser = argparse.ArgumentParser() + command = GenerateCommand(parser) + + base_dir = tmp_path / 'base' + base_dir.mkdir(parents=True, exist_ok=True) + + # existing file to trigger skip_if_exists + (base_dir / 'skip.txt').write_text('already') + + config = { + 'files': [ + {'skip.txt': {'content': 'new', 'skip_if_exists': True, 'config_variables': [], 'input_store': _ensure_store(tmp_path)}}, + ], + 'folders': [] + } + + args = _base_args(parser, tmp_path) + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(command, '_load_yaml_config', lambda *_: config) + command.execute(args) + + logs = caplog.text + assert 'Skipped (exists and skip_if_exists=true)' in logs + + +def test_dry_run_diff_summary_counts(tmp_path, caplog): + caplog.set_level(logging.INFO) + + parser = argparse.ArgumentParser() + command = GenerateCommand(parser) + + base_dir = tmp_path / 'base' + base_dir.mkdir(parents=True, exist_ok=True) + + # one existing for update, one new for create + (base_dir / 'update.txt').write_text('old') + + config = { + 'files': [ + {'create.txt': 'x'}, + {'update.txt': 'y'}, + ], + 'folders': [] + } + + args = _base_args(parser, tmp_path) + args.dry_run = True + args.diff = True + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(command, '_load_yaml_config', lambda *_: config) + command.execute(args) + + logs = caplog.text + assert '[DRY RUN] Would' in logs or '[DRY RUN] create' in logs or '[DRY RUN] update' in logs + assert '[DRY RUN] Would create' in logs or 'Would create' in logs or 'Would update' in logs + # Summary counters + assert '[DRY RUN] Would create:' in logs or 'Would create:' in logs + assert '[DRY RUN] Would update:' in logs or 'Would update:' in logs diff --git a/tests/test_messages_and_summary.py b/tests/test_messages_and_summary.py new file mode 100644 index 0000000..148ab0d --- /dev/null +++ b/tests/test_messages_and_summary.py @@ -0,0 +1,133 @@ +import argparse +import asyncio +import logging + +import pytest + +from struct_module.commands.generate import GenerateCommand +from struct_module.mcp_server import StructMCPServer + + +def _ensure_store(tmp_path): + store_dir = tmp_path / 'store' + store_dir.mkdir(parents=True, exist_ok=True) + p = store_dir / 'input.json' + p.write_text('{}') + return str(p) + + +def test_generate_summary_counts_created_updated(tmp_path, caplog): + # capture INFO logs for our modules + caplog.set_level(logging.INFO) + caplog.set_level(logging.INFO, logger='struct_module.file_item') + caplog.set_level(logging.INFO, logger='struct_module.commands.generate') + + parser = argparse.ArgumentParser() + command = GenerateCommand(parser) + args = parser.parse_args(['struct-x', str(tmp_path / 'base')]) + + base_dir = tmp_path / 'base' + base_dir.mkdir(parents=True, exist_ok=True) + + # Prepare one existing file (update) and one new file (create) + (base_dir / 'update.txt').write_text('old') + + config = { + 'files': [ + {'create.txt': 'new-content'}, + {'update.txt': 'newer-content'}, + ], + 'folders': [] + } + + args.output = 'file' + args.input_store = _ensure_store(tmp_path) + args.dry_run = False + args.diff = False + args.vars = None + args.backup = None + args.file_strategy = 'overwrite' + args.global_system_prompt = None + args.structures_path = None + args.non_interactive = True + + # Execute with mocked config + with pytest.MonkeyPatch().context() as mp: + mp.setattr(command, '_load_yaml_config', lambda *_: config) + command.execute(args) + + logs = caplog.text + assert 'Summary of actions:' in logs + assert 'Created:' in logs and 'Updated:' in logs + + +def test_fileitem_append_logs_message(tmp_path, caplog): + caplog.set_level(logging.INFO) + caplog.set_level(logging.INFO, logger='struct_module.file_item') + + parser = argparse.ArgumentParser() + command = GenerateCommand(parser) + args = parser.parse_args(['struct-x', str(tmp_path / 'base')]) + + base_dir = tmp_path / 'base' + base_dir.mkdir(parents=True, exist_ok=True) + + # Existing file to trigger append + (base_dir / 'append.txt').write_text('start\n') + + config = { + 'files': [ + {'append.txt': {'content': 'more', 'config_variables': [], 'input_store': _ensure_store(tmp_path)}}, + ], + 'folders': [] + } + + args.output = 'file' + args.input_store = _ensure_store(tmp_path) + args.dry_run = False + args.diff = False + args.vars = None + args.backup = None + args.file_strategy = 'append' + args.global_system_prompt = None + args.structures_path = None + args.non_interactive = True + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(command, '_load_yaml_config', lambda *_: config) + command.execute(args) + + logs = caplog.text + assert 'Appended:' in logs + + +def test_mcp_get_structure_info_rich_rendering(tmp_path): + # Create a temp YAML structure with folders/struct/with + yaml_path = tmp_path / 'my-struct.yaml' + yaml_path.write_text( + """ + description: Example + files: + - foo.txt: "bar" + folders: + - nested: + struct: + - sub/one + - sub/two + with: + team: devops + env: dev + """ + ) + + async def run(): + server = StructMCPServer() + return await server._handle_get_structure_info({ + 'structure_name': f'file://{yaml_path}', + }) + + result = asyncio.run(run()) + text = result.content[0].text + assert 'Folders:' in text + assert 'â€ĸ struct' in text or 'â€ĸ struct(s):' in text + assert 'â€ĸ with:' in text