From 57dd99325cab93a471424924b74777c3e7e2c01d Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Sat, 9 May 2026 16:37:32 -0300 Subject: [PATCH] Add explain command --- docs/cli-reference.md | 28 +++ structkit/commands/explain.py | 362 ++++++++++++++++++++++++++++++++++ structkit/commands/mcp.py | 2 + structkit/main.py | 2 + structkit/mcp_server.py | 86 ++++++++ tests/test_explain_command.py | 116 +++++++++++ 6 files changed, 596 insertions(+) create mode 100644 structkit/commands/explain.py create mode 100644 tests/test_explain_command.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index de21f97..d40a891 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -115,6 +115,34 @@ structkit generate - `--mappings-file MAPPINGS_FILE`: Path to a YAML file containing mappings to be used in templates (can be specified multiple times). - `-o {console,file}, --output {console,file}`: Output mode. + +### `explain` + +Preview how a structure definition resolves before generation. Unlike `generate --dry-run`, `explain` is structure-focused: it lists the files, folders, nested structures, remote file references, declared variables, resolved values, hooks, and conflict behavior without fetching remote content, creating directories, writing files, or executing hooks. + +**Usage:** + +```sh +structkit explain [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH] [-v VARS] [-f {overwrite,skip,append,rename,backup}] [--json] structure_definition [base_path] +``` + +**Arguments:** + +- `structure_definition`: Built-in structure name, custom structure name, or local YAML file path. Local `.yaml` and `.yml` files can be passed directly, or with `file://`. +- `base_path` (optional): Base path used to resolve generated paths and existing-file conflict behavior (default: `.`). +- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to custom structure definitions. Can be set via the `STRUCTKIT_STRUCTURES_PATH` environment variable. +- `-v VARS, --vars VARS`: Template variables in the format `KEY1=value1,KEY2=value2`; these are shown as resolved values and are used for nested `folders[].with` values. +- `-f {overwrite,skip,append,rename,backup}, --file-strategy {overwrite,skip,append,rename,backup}`: Strategy to report when a generated file already exists. +- `--json`: Print a machine-readable JSON explanation. + +Examples: + +```sh +structkit explain terraform/modules/generic +structkit explain ./my-struct.yaml --vars project_name=demo +structkit explain project/python --json +``` + ### `vars` Inspect variables declared by a structure definition without generating files. diff --git a/structkit/commands/explain.py b/structkit/commands/explain.py new file mode 100644 index 0000000..ce1fd72 --- /dev/null +++ b/structkit/commands/explain.py @@ -0,0 +1,362 @@ +import json +import os +from typing import Any, Dict, List, Optional, Set, Tuple + +import yaml + +from structkit.commands import Command +from structkit.completers import file_strategy_completer, structures_completer +from structkit.template_renderer import TemplateRenderer + + +class ExplainCommand(Command): + """Explain how a structure definition resolves without creating anything.""" + + def __init__(self, parser): + super().__init__(parser) + parser.description = "Preview how a structure definition resolves without generating files" + structure_arg = parser.add_argument('structure_definition', type=str, help='Structure definition name or path to a YAML file') + structure_arg.completer = structures_completer + parser.add_argument('base_path', nargs='?', default='.', type=str, help='Base path used to resolve generated paths and conflicts (default: current directory)') + parser.add_argument( + '-s', + '--structures-path', + type=str, + help='Path to structure definitions (env: STRUCTKIT_STRUCTURES_PATH)', + default=os.getenv('STRUCTKIT_STRUCTURES_PATH', None) + ) + parser.add_argument('-v', '--vars', type=str, help='Template variables in the format KEY1=value1,KEY2=value2') + parser.add_argument( + '-f', + '--file-strategy', + type=str, + choices=['overwrite', 'skip', 'append', 'rename', 'backup'], + default=os.getenv('STRUCTKIT_FILE_STRATEGY', 'overwrite'), + help='Strategy to report when generated files already exist (env: STRUCTKIT_FILE_STRATEGY)').completer = file_strategy_completer + parser.add_argument('--json', action='store_true', help='Output the explanation as JSON') + parser.set_defaults(func=self.execute) + + def execute(self, args): + explanation = self.explain( + args.structure_definition, + args.base_path, + structures_path=args.structures_path, + vars_str=args.vars, + file_strategy=args.file_strategy, + ) + if args.json: + print(json.dumps(explanation, indent=2)) + else: + print(self.format_text(explanation)) + + def explain( + self, + structure_definition: str, + base_path: str = '.', + structures_path: Optional[str] = None, + vars_str: Optional[str] = None, + file_strategy: str = 'overwrite', + ) -> Dict[str, Any]: + provided_vars = self._parse_template_vars(vars_str) + context = { + 'structures_path': structures_path, + 'provided_vars': provided_vars, + 'input_store': '/tmp/structkit/input.json', + 'non_interactive': True, + 'file_strategy': file_strategy, + } + explanation = { + 'structure': structure_definition, + 'base_path': base_path, + 'file_strategy': file_strategy, + 'creates_files': False, + 'executes_hooks': False, + 'variables': [], + 'hooks': {'pre': [], 'post': []}, + 'files': [], + 'folders': [], + 'nested_structures': [], + 'remote_files': [], + 'warnings': [], + } + self._collect(structure_definition, base_path, context, explanation, set(), path=[structure_definition]) + return explanation + + def format_text(self, explanation: Dict[str, Any]) -> str: + lines = [ + f"Structure explanation: {explanation['structure']}", + f"Base path: {explanation['base_path']}", + f"File strategy: {explanation['file_strategy']}", + "Safety: no files or folders will be created, and hooks will not be executed.", + "", + ] + + lines.append("Variables:") + if explanation['variables']: + for variable in explanation['variables']: + value = variable.get('resolved_value') + value_text = '' if value is None else str(value) + default = variable.get('default') + default_text = '' if default is None else f" default={default}" + lines.append(f" - {variable['name']}: value={value_text}{default_text} source={variable.get('source', 'unknown')}") + else: + lines.append(" - none") + + lines.append("") + lines.append("Hooks (not executed):") + hooks = explanation['hooks'] + if hooks['pre'] or hooks['post']: + for hook in hooks['pre']: + lines.append(f" - pre: {hook['command']} ({hook['structure']})") + for hook in hooks['post']: + lines.append(f" - post: {hook['command']} ({hook['structure']})") + else: + lines.append(" - none") + + lines.append("") + lines.append("Folders:") + if explanation['folders']: + for folder in explanation['folders']: + lines.append(f" - {folder['path']}") + else: + lines.append(" - none") + + lines.append("") + lines.append("Files:") + if explanation['files']: + for file_info in explanation['files']: + remote = f" remote={file_info['remote']}" if file_info.get('remote') else '' + lines.append(f" - {file_info['path']} action={file_info['conflict_action']}{remote}") + else: + lines.append(" - none") + + lines.append("") + lines.append("Nested structures:") + if explanation['nested_structures']: + for nested in explanation['nested_structures']: + vars_text = '' + if nested.get('vars'): + vars_text = " with " + ",".join(f"{k}={v}" for k, v in nested['vars'].items()) + lines.append(f" - {nested['structure']} -> {nested['base_path']}{vars_text}") + else: + lines.append(" - none") + + lines.append("") + lines.append("Remote files:") + if explanation['remote_files']: + for remote in explanation['remote_files']: + lines.append(f" - {remote['file']} -> {remote['path']}") + else: + lines.append(" - none") + + if explanation['warnings']: + lines.append("") + lines.append("Warnings:") + for warning in explanation['warnings']: + lines.append(f" - {warning}") + + return "\n".join(lines) + + def _collect( + self, + structure_definition: str, + base_path: str, + context: Dict[str, Any], + explanation: Dict[str, Any], + seen: Set[Tuple[str, str]], + path: List[str], + ): + config, source = self._load_yaml_config(structure_definition, context['structures_path']) + if config is None: + explanation['warnings'].append(f"Structure not found or could not be loaded: {structure_definition}") + return + if not isinstance(config, dict): + explanation['warnings'].append(f"Structure is not a mapping: {structure_definition}") + return + + key = (source or structure_definition, os.path.abspath(base_path)) + if key in seen: + explanation['warnings'].append(f"Skipped recursive structure reference: {' -> '.join(path)}") + return + seen.add(key) + + variables = config.get('variables', []) or [] + resolved_vars = self._resolve_variables(variables, context['provided_vars']) + self._append_variables(explanation, structure_definition, variables, resolved_vars, context['provided_vars']) + + for command in config.get('pre_hooks', []) or []: + explanation['hooks']['pre'].append({'structure': structure_definition, 'command': command}) + for command in config.get('post_hooks', []) or []: + explanation['hooks']['post'].append({'structure': structure_definition, 'command': command}) + + files = config.get('files', config.get('structure', [])) or [] + for item in files: + if not isinstance(item, dict): + explanation['warnings'].append(f"Unsupported file entry in {structure_definition}: {item}") + continue + for name, content in item.items(): + rendered_name = self._render_value(str(name), variables, resolved_vars) + file_path = os.path.normpath(os.path.join(base_path, rendered_name)) + remote = content.get('file') if isinstance(content, dict) else None + skip = bool(isinstance(content, dict) and content.get('skip', False)) + skip_if_exists = bool(isinstance(content, dict) and content.get('skip_if_exists', False)) + conflict_action = self._conflict_action(file_path, context['file_strategy'], skip, skip_if_exists) + file_info = { + 'structure': structure_definition, + 'name': rendered_name, + 'path': file_path, + 'exists': os.path.exists(file_path), + 'conflict_action': conflict_action, + 'remote': remote, + 'has_prompt': bool(isinstance(content, dict) and content.get('user_prompt')), + 'skip': skip, + 'skip_if_exists': skip_if_exists, + } + explanation['files'].append(file_info) + if remote: + explanation['remote_files'].append({'structure': structure_definition, 'file': remote, 'path': file_path}) + + folders = config.get('folders', []) or [] + for item in folders: + if not isinstance(item, dict): + folder_path = os.path.normpath(os.path.join(base_path, str(item))) + explanation['folders'].append({'structure': structure_definition, 'name': str(item), 'path': folder_path}) + continue + for folder, content in item.items(): + rendered_folder = self._render_value(str(folder), variables, resolved_vars) + folder_path = os.path.normpath(os.path.join(base_path, rendered_folder)) + explanation['folders'].append({'structure': structure_definition, 'name': rendered_folder, 'path': folder_path}) + if not isinstance(content, dict): + continue + + with_vars = self._render_with_vars(content.get('with', {}), variables, resolved_vars) + nested_vars = context['provided_vars'].copy() + nested_vars.update(with_vars) + structs = content.get('struct') or content.get('structkit') + if isinstance(structs, str): + structs = [structs] + if isinstance(structs, list): + for nested_struct in structs: + explanation['nested_structures'].append({ + 'structure': nested_struct, + 'base_path': folder_path, + 'parent': structure_definition, + 'vars': with_vars, + }) + nested_context = context.copy() + nested_context['provided_vars'] = nested_vars + self._collect(nested_struct, folder_path, nested_context, explanation, seen, path + [nested_struct]) + + seen.remove(key) + + def _load_yaml_config(self, structure_definition, structures_path): + if structure_definition.endswith((".yaml", ".yml")) and not structure_definition.startswith("file://"): + structure_definition = f"file://{structure_definition}" + + if structure_definition.startswith("file://") and structure_definition.endswith((".yaml", ".yml")): + file_path = structure_definition[7:] + else: + this_file = os.path.dirname(os.path.realpath(__file__)) + contribs_path = os.path.join(this_file, "..", "contribs") + file_path = os.path.join(contribs_path, f"{structure_definition}.yaml") + if structures_path: + file_path = os.path.join(structures_path, f"{structure_definition}.yaml") + if not os.path.exists(file_path): + file_path = os.path.join(contribs_path, f"{structure_definition}.yaml") + + if not os.path.exists(file_path): + self.logger.error(f"❗ File not found: {file_path}") + return None, file_path + + try: + with open(file_path, 'r') as f: + return yaml.safe_load(f) or {}, file_path + except (yaml.YAMLError, OSError) as exc: + self.logger.error(f"❗ Failed to load {file_path}: {exc}") + return None, file_path + + def _parse_template_vars(self, vars_str): + result = {} + if not vars_str: + return result + for token in [t.strip() for t in vars_str.strip(', ').split(',')]: + if not token or '=' not in token: + continue + key, value = token.split('=', 1) + key = key.strip() + if key: + result[key] = value + return result + + def _resolve_variables(self, variables, provided_vars): + resolved = {} + for item in variables: + if not isinstance(item, dict): + continue + for name, content in item.items(): + content = content or {} + if name in provided_vars: + resolved[name] = provided_vars[name] + elif isinstance(content, dict) and 'default' in content: + resolved[name] = content.get('default') + elif isinstance(content, dict) and (content.get('env') or content.get('default_from_env')): + env_key = content.get('env') or content.get('default_from_env') + if os.getenv(env_key) is not None: + resolved[name] = os.getenv(env_key) + for name, value in provided_vars.items(): + resolved.setdefault(name, value) + return resolved + + def _append_variables(self, explanation, structure_definition, variables, resolved_vars, provided_vars): + existing = {(item['structure'], item['name']) for item in explanation['variables']} + for item in variables: + if not isinstance(item, dict): + continue + for name, content in item.items(): + if (structure_definition, name) in existing: + continue + content = content or {} + source = 'provided' if name in provided_vars else ('default' if isinstance(content, dict) and 'default' in content else 'unresolved') + if isinstance(content, dict) and source == 'unresolved' and (content.get('env') or content.get('default_from_env')): + env_key = content.get('env') or content.get('default_from_env') + source = f"env:{env_key}" if os.getenv(env_key) is not None else source + explanation['variables'].append({ + 'structure': structure_definition, + 'name': name, + 'type': content.get('type', '') if isinstance(content, dict) else '', + 'default': content.get('default') if isinstance(content, dict) and 'default' in content else None, + 'required': bool(content.get('required', False)) if isinstance(content, dict) else False, + 'resolved_value': resolved_vars.get(name), + 'source': source, + }) + + def _render_value(self, value, variables, resolved_vars): + try: + renderer = TemplateRenderer(variables, '/tmp/structkit/explain-input.json', True, {}) + return renderer.render_template(value, resolved_vars) + except Exception as exc: + return f"{value} [unresolved: {exc}]" + + def _render_with_vars(self, with_config, variables, resolved_vars): + if not isinstance(with_config, dict): + return {} + rendered = {} + for key, value in with_config.items(): + rendered[key] = self._render_value(str(value), variables, resolved_vars) + return rendered + + def _conflict_action(self, path, file_strategy, skip=False, skip_if_exists=False): + exists = os.path.exists(path) + if skip: + return 'skip (skip=true)' + if skip_if_exists and exists: + return 'skip existing file (skip_if_exists=true)' + if not exists: + return 'create' + return { + 'overwrite': 'overwrite existing file', + 'skip': 'skip existing file', + 'append': 'append to existing file', + 'rename': 'rename new file', + 'backup': 'backup and overwrite existing file', + }.get(file_strategy, file_strategy) diff --git a/structkit/commands/mcp.py b/structkit/commands/mcp.py index dc603c1..eaef369 100644 --- a/structkit/commands/mcp.py +++ b/structkit/commands/mcp.py @@ -48,6 +48,8 @@ def execute(self, args): print("\nMCP tools available:") print(" - list_structures: List all available structure definitions") print(" - get_structure_info: Get detailed information about a structure") + print(" - get_structure_vars: Inspect variables declared by a specific structure") + print(" - explain_structure: Explain how a structure resolves without writing files") print(" - generate_structure: Generate structures with various options") print(" - validate_structure: Validate structure configuration files") print("\nExamples:") diff --git a/structkit/main.py b/structkit/main.py index 5a0eb7c..8f05631 100644 --- a/structkit/main.py +++ b/structkit/main.py @@ -6,6 +6,7 @@ from structkit.commands.generate import GenerateCommand from structkit.commands.info import InfoCommand from structkit.commands.vars import VarsCommand +from structkit.commands.explain import ExplainCommand from structkit.commands.validate import ValidateCommand from structkit.commands.list import ListCommand from structkit.commands.search import SearchCommand @@ -36,6 +37,7 @@ def get_parser(): ValidateCommand(subparsers.add_parser('validate', help='Validate the YAML configuration file')) GenerateCommand(subparsers.add_parser('generate', help='Generate the project structure')) VarsCommand(subparsers.add_parser('vars', help='Inspect structure variables')) + ExplainCommand(subparsers.add_parser('explain', help='Explain structure resolution without generating files')) ListCommand(subparsers.add_parser('list', help='List available structures')) SearchCommand(subparsers.add_parser('search', help='Search available structures by keyword')) GenerateSchemaCommand(subparsers.add_parser('generate-schema', help='Generate JSON schema for available structures')) diff --git a/structkit/mcp_server.py b/structkit/mcp_server.py index 3e17c04..bb8c1fa 100644 --- a/structkit/mcp_server.py +++ b/structkit/mcp_server.py @@ -7,6 +7,7 @@ 3. Generating structures with various options 4. Validating structure configurations 5. Inspecting structure variables +6. Explaining structure resolution without generating files """ import asyncio import logging @@ -20,6 +21,7 @@ from structkit.commands.generate import GenerateCommand from structkit.commands.validate import ValidateCommand from structkit.commands.vars import VarsCommand +from structkit.commands.explain import ExplainCommand from structkit import __version__ @@ -169,6 +171,37 @@ class Args: return f"Dry run completed for structure '{structure_definition}' at '{base_path}'" return f"Structure '{structure_definition}' generated successfully at '{base_path}'" + def _explain_structure_logic( + self, + structure_definition: Optional[str], + base_path: str = ".", + structures_path: Optional[str] = None, + vars: Optional[Dict[str, str]] = None, + output: str = "text", + file_strategy: str = "overwrite", + ) -> str: + if not structure_definition: + return "Error: structure_definition is required" + + import argparse + dummy_parser = argparse.ArgumentParser() + explain_command = ExplainCommand(dummy_parser) + vars_str = None + if vars: + vars_str = ",".join([f"{k}={v}" for k, v in vars.items()]) + explanation = explain_command.explain( + structure_definition, + base_path, + structures_path=structures_path, + vars_str=vars_str, + file_strategy=file_strategy, + ) + + if output == "json": + import json + return json.dumps(explanation, indent=2) + return explain_command.format_text(explanation) + def _validate_structure_logic(self, yaml_file: Optional[str]) -> str: if not yaml_file: return "Error: yaml_file is required" @@ -274,6 +307,38 @@ async def get_structure_vars( self.logger.debug(f"MCP response: get_structure_vars len={len(result)} preview=\n{preview}") return result + @self.app.tool(name="explain_structure", description="Explain how a structure resolves without creating files or executing hooks") + async def explain_structure( + structure_definition: str, + base_path: str = ".", + structures_path: Optional[str] = None, + vars: Optional[Dict[str, str]] = None, + output: str = "text", + file_strategy: str = "overwrite", + ) -> str: + self.logger.debug( + "MCP request: explain_structure args=%s", + { + "structure_definition": structure_definition, + "base_path": base_path, + "structures_path": structures_path, + "vars": vars, + "output": output, + "file_strategy": file_strategy, + }, + ) + result = self._explain_structure_logic( + structure_definition, + base_path, + structures_path, + vars, + output, + file_strategy, + ) + preview = result if len(result) <= 1000 else result[:1000] + f"... [truncated {len(result)-1000} chars]" + self.logger.debug(f"MCP response: explain_structure len={len(result)} preview=\n{preview}") + return result + @self.app.tool(name="generate_structure", description="Generate a project structure using specified definition and options") async def generate_structure( structure_definition: str, @@ -415,6 +480,27 @@ def __init__(self, content): return MockResult([MockContent(result_text)]) + async def _handle_explain_structure(self, params: Dict[str, Any]): + """Compatibility method for tests that expect MCP-style responses.""" + result_text = self._explain_structure_logic( + params.get('structure_definition'), + params.get('base_path', '.'), + params.get('structures_path'), + params.get('vars'), + params.get('output', 'text'), + params.get('file_strategy', 'overwrite'), + ) + + class MockContent: + def __init__(self, text): + self.text = text + + class MockResult: + def __init__(self, content): + self.content = content + + return MockResult([MockContent(result_text)]) + async def main(): logging.basicConfig(level=logging.INFO) diff --git a/tests/test_explain_command.py b/tests/test_explain_command.py new file mode 100644 index 0000000..88b55e6 --- /dev/null +++ b/tests/test_explain_command.py @@ -0,0 +1,116 @@ +import argparse +import json +import os + +import yaml + +from structkit.commands.explain import ExplainCommand +from structkit.main import get_parser +from structkit.mcp_server import StructMCPServer + + +def write_yaml(path, data): + path.write_text(yaml.safe_dump(data)) + + +def test_explain_collects_nested_remote_hooks_vars_and_json(tmp_path): + structures_path = tmp_path / "structures" + structures_path.mkdir() + base_path = tmp_path / "out" + parent_path = tmp_path / "parent.yaml" + child_path = structures_path / "child.yaml" + + write_yaml(child_path, { + "variables": [ + {"module_name": {"type": "string", "default": "child_default"}}, + ], + "files": [ + {"{{@ module_name @}}.txt": {"file": "https://example.com/template.txt"}}, + ], + }) + write_yaml(parent_path, { + "variables": [ + {"project_name": {"type": "string", "default": "demo"}}, + ], + "pre_hooks": ["touch should-not-run-pre"], + "post_hooks": ["touch should-not-run-post"], + "files": [ + {"README.md": {"content": "# {{@ project_name @}}"}}, + ], + "folders": [ + {"modules": {"struct": "child", "with": {"module_name": "{{@ project_name @}}-module"}}}, + ], + }) + + command = ExplainCommand(argparse.ArgumentParser()) + explanation = command.explain( + str(parent_path), + str(base_path), + structures_path=str(structures_path), + vars_str="project_name=acme", + ) + + assert explanation["creates_files"] is False + assert explanation["executes_hooks"] is False + assert not base_path.exists() + assert explanation["hooks"]["pre"][0]["command"] == "touch should-not-run-pre" + assert explanation["hooks"]["post"][0]["command"] == "touch should-not-run-post" + assert explanation["variables"][0]["name"] == "project_name" + assert explanation["variables"][0]["resolved_value"] == "acme" + assert explanation["folders"][0]["path"] == os.path.join(str(base_path), "modules") + assert explanation["nested_structures"][0]["structure"] == "child" + assert explanation["nested_structures"][0]["vars"] == {"module_name": "acme-module"} + assert explanation["remote_files"] == [{ + "structure": "child", + "file": "https://example.com/template.txt", + "path": os.path.join(str(base_path), "modules", "acme-module.txt"), + }] + + rendered = command.format_text(explanation) + assert "Safety: no files or folders will be created" in rendered + assert "README.md action=create" in rendered + assert "https://example.com/template.txt" in rendered + + +def test_explain_reports_existing_file_conflict_strategy(tmp_path): + structure_path = tmp_path / "structure.yaml" + base_path = tmp_path / "out" + base_path.mkdir() + (base_path / "README.md").write_text("old") + write_yaml(structure_path, {"files": [{"README.md": {"content": "new"}}]}) + + command = ExplainCommand(argparse.ArgumentParser()) + explanation = command.explain(str(structure_path), str(base_path), file_strategy="skip") + + assert explanation["files"][0]["exists"] is True + assert explanation["files"][0]["conflict_action"] == "skip existing file" + assert (base_path / "README.md").read_text() == "old" + + +def test_explain_command_is_registered_and_outputs_json(tmp_path, capsys): + structure_path = tmp_path / "structure.yaml" + write_yaml(structure_path, {"files": [{"README.md": {"content": "hello"}}]}) + + parser = get_parser() + args = parser.parse_args(["explain", str(structure_path), str(tmp_path / "out"), "--json"]) + args.func(args) + data = json.loads(capsys.readouterr().out) + + assert data["structure"] == str(structure_path) + assert data["files"][0]["name"] == "README.md" + + +def test_mcp_explain_structure_logic_and_tool_registration(tmp_path): + structure_path = tmp_path / "structure.yaml" + write_yaml(structure_path, {"files": [{"app.py": {"content": "print('hi')"}}]}) + + server = StructMCPServer() + tool_names = [tool.name for tool in __import__('asyncio').run(server.app.list_tools())] + assert "explain_structure" in tool_names + + json_text = server._explain_structure_logic(str(structure_path), str(tmp_path / "out"), output="json") + data = json.loads(json_text) + assert data["files"][0]["path"] == os.path.join(str(tmp_path / "out"), "app.py") + + text = server._explain_structure_logic(str(structure_path), str(tmp_path / "out")) + assert "Structure explanation" in text