diff --git a/docs/reference/presets.md b/docs/reference/presets.md index b8f318ac9e..1098abfb42 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -47,6 +47,8 @@ specify preset list Lists installed presets with their versions, descriptions, template counts, and current status. +Presets are printed in **resolution/precedence order**: the highest-precedence preset (lowest priority number) is listed first, and ties on priority are broken alphabetically by preset id. This matches the order used when composing commands and resolving templates, so the top entry is the one that wins for overlapping files. + ## Preset Info ```bash diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py index b7e5ad06e5..48d5c9f14f 100644 --- a/src/specify_cli/presets/_commands.py +++ b/src/specify_cli/presets/_commands.py @@ -59,7 +59,15 @@ def preset_list(): console.print(" [cyan]specify preset add [/cyan]") return - console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n") + # Sort by actual resolution precedence: lower priority number wins, ties + # broken by preset id (matching PresetRegistry.list_by_priority()). This + # keeps the printed order aligned with how presets are composed/resolved. + installed = sorted( + installed, + key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))), + ) + + console.print("\n[bold cyan]Installed Presets[/bold cyan] [dim](in resolution order — highest precedence first)[/dim]\n") for pack in installed: status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]" pri = pack.get('priority', 10) @@ -75,6 +83,8 @@ def preset_list(): console.print(f" [dim]Templates: {pack['template_count']}[/dim]") console.print() + console.print("[dim]Lower priority number = higher precedence. Ties are broken by preset id (alphabetical).[/dim]") + @preset_app.command("add") def preset_add( diff --git a/tests/test_presets.py b/tests/test_presets.py index 317a1b437c..089f786e15 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -13680,6 +13680,73 @@ def test_resolve_renders_composition_strategy_labels(self, temp_dir, project_dir assert "[base]" in output, output assert "[append]" in output, output + +class TestPresetListOrdering: + """``preset list`` must print presets in actual resolution/precedence order. + + Regression coverage for #4086: the printed order was registry/insertion + order, so a preset with a *higher* priority number (lower precedence) could + appear before one with a lower number, misleading users about which preset + wins. Output must be sorted by (priority, id) to match + ``PresetRegistry.list_by_priority()``. + """ + + def _install(self, temp_dir, project_dir, pack_id, priority): + from specify_cli.presets import PresetManager + + src = temp_dir / f"src-{pack_id}" + (src / "templates").mkdir(parents=True) + (src / "templates" / "spec-template.md").write_text("# tmpl\n") + (src / "preset.yml").write_text(yaml.dump({ + "schema_version": "1.0", + "preset": { + "id": pack_id, + "name": pack_id, + "version": "1.0.0", + "description": "plain description", + }, + "requires": {"speckit_version": ">=0.0.1"}, + "provides": {"templates": [{ + "type": "template", + "name": "spec-template", + "file": "templates/spec-template.md", + }]}, + })) + PresetManager(project_dir).install_from_directory(src, "9.9.9", priority) + + def _invoke(self, project_dir, args): + from typer.testing import CliRunner + from unittest.mock import patch + from specify_cli import app + + with patch.object(Path, "cwd", return_value=project_dir): + return CliRunner().invoke(app, args) + + def test_list_sorted_by_priority(self, temp_dir, project_dir): + """Lower priority number is listed first regardless of install order.""" + # Install in an order that does NOT match precedence. + self._install(temp_dir, project_dir, "copilot-sub-agents", priority=100) + self._install(temp_dir, project_dir, "lean", priority=10) + + result = self._invoke(project_dir, ["preset", "list"]) + assert result.exit_code == 0, result.output + output = strip_ansi(result.output) + # `lean` (priority 10) must appear before `copilot-sub-agents` (100). + assert output.index("(lean)") < output.index("(copilot-sub-agents)"), output + assert "resolution order" in output, output + assert "Ties are broken by preset id" in output, output + + def test_list_ties_broken_by_id(self, temp_dir, project_dir): + """Equal priority ties are broken alphabetically by preset id.""" + self._install(temp_dir, project_dir, "zebra", priority=10) + self._install(temp_dir, project_dir, "alpha", priority=10) + + result = self._invoke(project_dir, ["preset", "list"]) + assert result.exit_code == 0, result.output + output = strip_ansi(result.output) + assert output.index("(alpha)") < output.index("(zebra)"), output + + class TestConstitutionSyncPreset: """The bundled opt-in ``constitution-sync`` preset re-adds materialization.