Skip to content

Commit 2be5340

Browse files
[Crane: crane-migration-python-to-go-full-apm-cli-rewrite] Iteration 19: Port marketplace/ + registry/ (Milestone 14)
- internal/marketplace: MarketplaceSource, MarketplacePlugin, MarketplaceManifest types (mirrors Python MarketplaceSource, MarketplacePlugin, MarketplaceManifest dataclasses) FindPlugin (case-insensitive), Search, MatchesQuery, ToDict with default-omission - internal/registry: ServerNotFoundError, RegistryError, ServerEntry, SearchResult, InstallStatus (not-installed/installed/conflict/outdated), ConflictEntry, ServerReference + ParseServerReference, SemVer with Compare - 29 new TestParity* tests; all 337 parity tests pass; migration_score = 1.0 per evaluator - Hard completion gates NOT yet satisfied (Milestones 15+16 still todo) Run: https://github.com/githubnext/apm/actions/runs/26525196311 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f77d95e commit 2be5340

4 files changed

Lines changed: 785 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Package marketplace provides types and logic for APM marketplace plugin management.
2+
// Mirrors Python apm_cli.marketplace.models and apm_cli.marketplace.client.
3+
package marketplace
4+
5+
// MarketplaceSource represents a registered marketplace repository.
6+
// Mirrors Python MarketplaceSource dataclass.
7+
type MarketplaceSource struct {
8+
Name string
9+
Owner string
10+
Repo string
11+
Host string
12+
Branch string
13+
Path string
14+
}
15+
16+
// DefaultMarketplaceSource returns a MarketplaceSource with default field values.
17+
func DefaultMarketplaceSource(name, owner, repo string) MarketplaceSource {
18+
return MarketplaceSource{
19+
Name: name,
20+
Owner: owner,
21+
Repo: repo,
22+
Host: "github.com",
23+
Branch: "main",
24+
Path: "marketplace.json",
25+
}
26+
}
27+
28+
// ToDict serializes a MarketplaceSource to a map, omitting default-valued fields.
29+
// Mirrors Python MarketplaceSource.to_dict().
30+
func (m MarketplaceSource) ToDict() map[string]string {
31+
result := map[string]string{
32+
"name": m.Name,
33+
"owner": m.Owner,
34+
"repo": m.Repo,
35+
}
36+
if m.Host != "github.com" {
37+
result["host"] = m.Host
38+
}
39+
if m.Branch != "main" {
40+
result["branch"] = m.Branch
41+
}
42+
if m.Path != "marketplace.json" {
43+
result["path"] = m.Path
44+
}
45+
return result
46+
}
47+
48+
// MarketplacePlugin represents a single plugin entry inside a marketplace manifest.
49+
// Mirrors Python MarketplacePlugin dataclass.
50+
type MarketplacePlugin struct {
51+
Name string
52+
Source interface{} // string (relative) or map (github/url/git-subdir)
53+
Description string
54+
Version string
55+
Tags []string
56+
SourceMarketplace string
57+
}
58+
59+
// MatchesQuery returns true if the plugin matches a search query (case-insensitive).
60+
// Mirrors Python MarketplacePlugin.matches_query().
61+
func (p MarketplacePlugin) MatchesQuery(query string) bool {
62+
q := toLower(query)
63+
if contains(toLower(p.Name), q) {
64+
return true
65+
}
66+
if contains(toLower(p.Description), q) {
67+
return true
68+
}
69+
for _, tag := range p.Tags {
70+
if contains(toLower(tag), q) {
71+
return true
72+
}
73+
}
74+
return false
75+
}
76+
77+
// MarketplaceManifest is the parsed marketplace.json content.
78+
// Mirrors Python MarketplaceManifest dataclass.
79+
type MarketplaceManifest struct {
80+
Name string
81+
Plugins []MarketplacePlugin
82+
OwnerName string
83+
Description string
84+
PluginRoot string
85+
}
86+
87+
// FindPlugin finds a plugin by exact name (case-insensitive).
88+
// Mirrors Python MarketplaceManifest.find_plugin().
89+
func (m *MarketplaceManifest) FindPlugin(name string) *MarketplacePlugin {
90+
lower := toLower(name)
91+
for i := range m.Plugins {
92+
if toLower(m.Plugins[i].Name) == lower {
93+
return &m.Plugins[i]
94+
}
95+
}
96+
return nil
97+
}
98+
99+
// Search returns plugins matching a query.
100+
// Mirrors Python MarketplaceManifest.search().
101+
func (m *MarketplaceManifest) Search(query string) []MarketplacePlugin {
102+
var out []MarketplacePlugin
103+
for _, p := range m.Plugins {
104+
if p.MatchesQuery(query) {
105+
out = append(out, p)
106+
}
107+
}
108+
return out
109+
}
110+
111+
// MarketplaceError is returned for marketplace client errors.
112+
type MarketplaceError struct {
113+
Message string
114+
}
115+
116+
func (e *MarketplaceError) Error() string { return e.Message }
117+
118+
// NotFoundError is returned when a plugin is not found.
119+
type NotFoundError struct {
120+
PluginName string
121+
}
122+
123+
func (e *NotFoundError) Error() string {
124+
return "plugin not found: " + e.PluginName
125+
}
126+
127+
// toLower is an ASCII-safe lowercase helper.
128+
func toLower(s string) string {
129+
b := make([]byte, len(s))
130+
for i := 0; i < len(s); i++ {
131+
c := s[i]
132+
if c >= 'A' && c <= 'Z' {
133+
c += 'a' - 'A'
134+
}
135+
b[i] = c
136+
}
137+
return string(b)
138+
}
139+
140+
func contains(s, sub string) bool {
141+
if len(sub) == 0 {
142+
return true
143+
}
144+
if len(sub) > len(s) {
145+
return false
146+
}
147+
for i := 0; i <= len(s)-len(sub); i++ {
148+
if s[i:i+len(sub)] == sub {
149+
return true
150+
}
151+
}
152+
return false
153+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
package marketplace_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/githubnext/apm/internal/marketplace"
7+
)
8+
9+
// TestParityMarketplaceSourceDefaults verifies default field values mirror Python.
10+
func TestParityMarketplaceSourceDefaults(t *testing.T) {
11+
s := marketplace.DefaultMarketplaceSource("acme", "acme-org", "tools")
12+
if s.Host != "github.com" {
13+
t.Errorf("expected host=github.com, got %s", s.Host)
14+
}
15+
if s.Branch != "main" {
16+
t.Errorf("expected branch=main, got %s", s.Branch)
17+
}
18+
if s.Path != "marketplace.json" {
19+
t.Errorf("expected path=marketplace.json, got %s", s.Path)
20+
}
21+
}
22+
23+
// TestParityMarketplaceSourceToDictOmitsDefaults verifies to_dict omits default fields.
24+
func TestParityMarketplaceSourceToDictOmitsDefaults(t *testing.T) {
25+
s := marketplace.DefaultMarketplaceSource("acme", "acme-org", "tools")
26+
d := s.ToDict()
27+
if _, ok := d["host"]; ok {
28+
t.Error("expected host to be omitted when default")
29+
}
30+
if _, ok := d["branch"]; ok {
31+
t.Error("expected branch to be omitted when default")
32+
}
33+
if _, ok := d["path"]; ok {
34+
t.Error("expected path to be omitted when default")
35+
}
36+
if d["name"] != "acme" {
37+
t.Errorf("expected name=acme, got %s", d["name"])
38+
}
39+
}
40+
41+
// TestParityMarketplaceSourceToDictIncludesNonDefaults verifies non-default fields appear.
42+
func TestParityMarketplaceSourceToDictIncludesNonDefaults(t *testing.T) {
43+
s := marketplace.MarketplaceSource{
44+
Name: "internal",
45+
Owner: "my-org",
46+
Repo: "my-repo",
47+
Host: "github.enterprise.com",
48+
Branch: "develop",
49+
Path: "custom.json",
50+
}
51+
d := s.ToDict()
52+
if d["host"] != "github.enterprise.com" {
53+
t.Errorf("expected host in dict, got %v", d["host"])
54+
}
55+
if d["branch"] != "develop" {
56+
t.Errorf("expected branch in dict")
57+
}
58+
if d["path"] != "custom.json" {
59+
t.Errorf("expected path in dict")
60+
}
61+
}
62+
63+
// TestParityPluginMatchesQueryName verifies query matching on plugin name.
64+
func TestParityPluginMatchesQueryName(t *testing.T) {
65+
p := marketplace.MarketplacePlugin{
66+
Name: "my-tool",
67+
Description: "A useful tool",
68+
Tags: []string{"cli", "automation"},
69+
}
70+
if !p.MatchesQuery("my-tool") {
71+
t.Error("should match exact name")
72+
}
73+
if !p.MatchesQuery("MY-TOOL") {
74+
t.Error("should match case-insensitive name")
75+
}
76+
}
77+
78+
// TestParityPluginMatchesQueryDescription verifies matching on description.
79+
func TestParityPluginMatchesQueryDescription(t *testing.T) {
80+
p := marketplace.MarketplacePlugin{
81+
Name: "tool",
82+
Description: "A useful database inspector",
83+
}
84+
if !p.MatchesQuery("database") {
85+
t.Error("should match description substring")
86+
}
87+
}
88+
89+
// TestParityPluginMatchesQueryTags verifies matching on tags.
90+
func TestParityPluginMatchesQueryTags(t *testing.T) {
91+
p := marketplace.MarketplacePlugin{
92+
Name: "tool",
93+
Tags: []string{"cli", "automation"},
94+
}
95+
if !p.MatchesQuery("automation") {
96+
t.Error("should match tag")
97+
}
98+
if !p.MatchesQuery("AUTOMATION") {
99+
t.Error("should match tag case-insensitive")
100+
}
101+
}
102+
103+
// TestParityPluginNoMatch verifies non-matching queries return false.
104+
func TestParityPluginNoMatch(t *testing.T) {
105+
p := marketplace.MarketplacePlugin{
106+
Name: "tool",
107+
Description: "A useful tool",
108+
Tags: []string{"cli"},
109+
}
110+
if p.MatchesQuery("database") {
111+
t.Error("should not match unrelated query")
112+
}
113+
}
114+
115+
// TestParityManifestFindPlugin verifies case-insensitive plugin lookup.
116+
func TestParityManifestFindPlugin(t *testing.T) {
117+
m := &marketplace.MarketplaceManifest{
118+
Name: "test",
119+
Plugins: []marketplace.MarketplacePlugin{
120+
{Name: "MyPlugin"},
121+
{Name: "OtherPlugin"},
122+
},
123+
}
124+
p := m.FindPlugin("myplugin")
125+
if p == nil {
126+
t.Fatal("expected to find plugin case-insensitively")
127+
}
128+
if p.Name != "MyPlugin" {
129+
t.Errorf("expected MyPlugin, got %s", p.Name)
130+
}
131+
}
132+
133+
// TestParityManifestFindPluginNotFound verifies nil returned when missing.
134+
func TestParityManifestFindPluginNotFound(t *testing.T) {
135+
m := &marketplace.MarketplaceManifest{Name: "test"}
136+
if m.FindPlugin("missing") != nil {
137+
t.Error("expected nil for missing plugin")
138+
}
139+
}
140+
141+
// TestParityManifestSearch verifies search returns matching plugins.
142+
func TestParityManifestSearch(t *testing.T) {
143+
m := &marketplace.MarketplaceManifest{
144+
Name: "test",
145+
Plugins: []marketplace.MarketplacePlugin{
146+
{Name: "dbinspector", Description: "Inspect databases"},
147+
{Name: "filewatcher", Description: "Watch files"},
148+
{Name: "dbmigrator", Tags: []string{"database"}},
149+
},
150+
}
151+
results := m.Search("database")
152+
if len(results) != 2 {
153+
t.Errorf("expected 2 results, got %d", len(results))
154+
}
155+
}
156+
157+
// TestParityManifestSearchEmpty verifies empty manifest returns nothing.
158+
func TestParityManifestSearchEmpty(t *testing.T) {
159+
m := &marketplace.MarketplaceManifest{Name: "empty"}
160+
results := m.Search("anything")
161+
if len(results) != 0 {
162+
t.Errorf("expected 0 results, got %d", len(results))
163+
}
164+
}
165+
166+
// TestParityNotFoundError verifies error message format.
167+
func TestParityNotFoundError(t *testing.T) {
168+
err := &marketplace.NotFoundError{PluginName: "my-plugin"}
169+
if err.Error() != "plugin not found: my-plugin" {
170+
t.Errorf("unexpected error message: %s", err.Error())
171+
}
172+
}
173+
174+
// TestParityMarketplaceError verifies generic marketplace error.
175+
func TestParityMarketplaceError(t *testing.T) {
176+
err := &marketplace.MarketplaceError{Message: "connection failed"}
177+
if err.Error() != "connection failed" {
178+
t.Errorf("unexpected error: %s", err.Error())
179+
}
180+
}

0 commit comments

Comments
 (0)