diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index 35269af858..ef4bf898dc 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -292,8 +292,13 @@ def preset_search( console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n") for pack in results: - console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}") - console.print(f" {pack.get('description', '')}") + name = _escape_markup(str(pack.get("name", pack["id"]))) + pack_id = _escape_markup(str(pack["id"])) + version = _escape_markup(str(pack.get("version", "?"))) + console.print(f" [bold]{name}[/bold] ({pack_id}) v{version}") + console.print( + f" {_escape_markup(str(pack.get('description', '')))}" + ) tags = pack.get("tags", []) if isinstance(tags, list) and tags: tags_str = _escape_markup(", ".join(str(t) for t in tags)) @@ -375,6 +380,7 @@ def preset_info( from . import PresetCatalog, PresetManager, PresetError project_root = _require_specify_project() + safe_preset_id = _escape_markup(str(preset_id)) # Check if installed locally first manager = PresetManager(project_root) local_pack = manager.get_pack(preset_id) @@ -417,21 +423,32 @@ def preset_info( console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)") raise typer.Exit(1) - console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n") - console.print(f" ID: {pack_info['id']}") - console.print(f" Version: {pack_info.get('version', '?')}") - console.print(f" Description: {pack_info.get('description', '')}") + name = _escape_markup(str(pack_info.get("name", preset_id))) + console.print(f"\n[bold cyan]Preset: {name}[/bold cyan]\n") + console.print(f" ID: {_escape_markup(str(pack_info['id']))}") + console.print( + f" Version: {_escape_markup(str(pack_info.get('version', '?')))}" + ) + console.print( + f" Description: {_escape_markup(str(pack_info.get('description', '')))}" + ) if pack_info.get("author"): - console.print(f" Author: {pack_info['author']}") + console.print( + f" Author: {_escape_markup(str(pack_info['author']))}" + ) catalog_tags = pack_info.get("tags", []) if isinstance(catalog_tags, list) and catalog_tags: console.print(f" Tags: {', '.join(str(t) for t in catalog_tags)}") if pack_info.get("repository"): - console.print(f" Repository: {pack_info['repository']}") + console.print( + f" Repository: {_escape_markup(str(pack_info['repository']))}" + ) if pack_info.get("license"): - console.print(f" License: {pack_info['license']}") + console.print( + f" License: {_escape_markup(str(pack_info['license']))}" + ) console.print("\n [yellow]Status: not installed[/yellow]") - console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]") + console.print(f" Install with: [cyan]specify preset add {safe_preset_id}[/cyan]") console.print() diff --git a/tests/test_presets.py b/tests/test_presets.py index f3a03b0f79..8a8ff464d7 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -12104,3 +12104,69 @@ def test_search_escapes_rich_markup_in_tags(self, project_dir): assert result.exit_code == 0, result.output assert "[bold]ci" in strip_ansi(result.output) + + +class TestPresetCatalogRichMarkup: + """Catalog metadata must render as literal text in Rich output.""" + + MARKUP_PRESET = { + "id": "[red]markup-id[/red]", + "name": "[green]Markup Name[/green]", + "version": "[blue]1.0.0[/blue]", + "description": "[yellow]Markup Description[/yellow]", + "author": "[magenta]Markup Author[/magenta]", + "tags": ["[italic]markup-tag[/italic]"], + "repository": "[bold]Markup Repository[/bold]", + "license": "[cyan]Markup License[/cyan]", + } + + def test_search_escapes_catalog_markup(self, project_dir): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir), patch.object( + PresetCatalog, + "search", + return_value=[self.MARKUP_PRESET], + ): + result = CliRunner().invoke(app, ["preset", "search"]) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for value in ( + self.MARKUP_PRESET["id"], + self.MARKUP_PRESET["name"], + self.MARKUP_PRESET["version"], + self.MARKUP_PRESET["description"], + ): + assert value in output + + def test_info_escapes_catalog_markup(self, project_dir): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir), patch.object( + PresetCatalog, + "get_pack_info", + return_value=self.MARKUP_PRESET, + ): + result = CliRunner().invoke( + app, + ["preset", "info", self.MARKUP_PRESET["id"]], + ) + + assert result.exit_code == 0, result.output + output = " ".join(strip_ansi(result.output).split()) + for field in ( + "id", + "name", + "version", + "description", + "author", + "repository", + "license", + ): + value = self.MARKUP_PRESET[field] + assert value in output