Skip to content

Commit b99b21d

Browse files
committed
feat: add ruby support
1 parent 9926868 commit b99b21d

5 files changed

Lines changed: 379 additions & 0 deletions

File tree

internal/cli/parse.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/matzehuels/stacktower/pkg/source/metadata"
1515
"github.com/matzehuels/stacktower/pkg/source/python"
1616
"github.com/matzehuels/stacktower/pkg/source/rust"
17+
"github.com/matzehuels/stacktower/pkg/source/ruby"
1718
)
1819

1920
type parseOpts struct {
@@ -47,6 +48,8 @@ func newParseCmd() *cobra.Command {
4748
func() (source.Parser, error) { return rust.NewParser(source.DefaultCacheTTL) }, &opts))
4849
cmd.AddCommand(newParserCmd("javascript <package>", "Parse JavaScript package dependencies from npm",
4950
func() (source.Parser, error) { return javascript.NewParser(source.DefaultCacheTTL) }, &opts))
51+
cmd.AddCommand(newParserCmd("ruby <gem>", "Parse Ruby gem dependencies from RubyGems",
52+
func() (source.Parser, error) { return ruby.NewParser(source.DefaultCacheTTL) }, &opts))
5053

5154
return cmd
5255
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package rubygems
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
"time"
9+
10+
"github.com/matzehuels/stacktower/pkg/integrations"
11+
)
12+
13+
type GemInfo struct {
14+
Name string
15+
Version string
16+
Dependencies []string
17+
SourceCodeURI string
18+
HomepageURI string
19+
Description string
20+
License string
21+
Downloads int
22+
Authors string
23+
}
24+
25+
type Client struct {
26+
integrations.BaseClient
27+
baseURL string
28+
}
29+
30+
func NewClient(cacheTTL time.Duration) (*Client, error) {
31+
cache, err := integrations.NewCache(cacheTTL)
32+
if err != nil {
33+
return nil, err
34+
}
35+
return &Client{
36+
BaseClient: integrations.BaseClient{
37+
HTTP: integrations.NewHTTPClient(),
38+
Cache: cache,
39+
},
40+
baseURL: "https://rubygems.org/api/v1",
41+
}, nil
42+
}
43+
44+
func (c *Client) FetchGem(ctx context.Context, gem string, refresh bool) (*GemInfo, error) {
45+
gem = normalizeName(gem)
46+
cacheKey := "rubygems:" + gem
47+
48+
var info GemInfo
49+
err := c.FetchWithCache(ctx, cacheKey, refresh, func() error {
50+
return c.fetchGem(ctx, gem, &info)
51+
}, &info)
52+
if err != nil {
53+
return nil, err
54+
}
55+
return &info, nil
56+
}
57+
58+
func (c *Client) fetchGem(ctx context.Context, gem string, info *GemInfo) error {
59+
url := fmt.Sprintf("%s/gems/%s.json", c.baseURL, gem)
60+
61+
var data gemResponse
62+
if err := c.DoRequest(ctx, url, nil, &data); err != nil {
63+
if errors.Is(err, integrations.ErrNotFound) {
64+
return fmt.Errorf("%w: rubygems gem %s", err, gem)
65+
}
66+
return err
67+
}
68+
69+
*info = GemInfo{
70+
Name: data.Name,
71+
Version: data.Version,
72+
Description: data.Info,
73+
License: joinLicenses(data.Licenses),
74+
SourceCodeURI: data.SourceCodeURI,
75+
HomepageURI: data.HomepageURI,
76+
Downloads: data.Downloads,
77+
Authors: data.Authors,
78+
Dependencies: extractDeps(data.Dependencies),
79+
}
80+
return nil
81+
}
82+
83+
func extractDeps(deps dependenciesResponse) []string {
84+
seen := make(map[string]bool)
85+
var result []string
86+
87+
// Only include runtime dependencies, skip development dependencies
88+
for _, dep := range deps.Runtime {
89+
name := normalizeName(dep.Name)
90+
if !seen[name] {
91+
seen[name] = true
92+
result = append(result, name)
93+
}
94+
}
95+
return result
96+
}
97+
98+
func joinLicenses(licenses []string) string {
99+
if len(licenses) == 0 {
100+
return ""
101+
}
102+
return strings.Join(licenses, ", ")
103+
}
104+
105+
func normalizeName(name string) string {
106+
return strings.ToLower(strings.TrimSpace(name))
107+
}
108+
109+
type gemResponse struct {
110+
Name string `json:"name"`
111+
Version string `json:"version"`
112+
Info string `json:"info"`
113+
Licenses []string `json:"licenses"`
114+
SourceCodeURI string `json:"source_code_uri"`
115+
HomepageURI string `json:"homepage_uri"`
116+
Downloads int `json:"downloads"`
117+
Authors string `json:"authors"`
118+
Dependencies dependenciesResponse `json:"dependencies"`
119+
}
120+
121+
type dependenciesResponse struct {
122+
Development []dependencyInfo `json:"development"`
123+
Runtime []dependencyInfo `json:"runtime"`
124+
}
125+
126+
type dependencyInfo struct {
127+
Name string `json:"name"`
128+
Requirements string `json:"requirements"`
129+
}
130+
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package rubygems
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
"time"
11+
12+
"github.com/matzehuels/stacktower/pkg/integrations"
13+
)
14+
15+
func TestClient_FetchGem(t *testing.T) {
16+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17+
if r.URL.Path == "/api/v1/gems/rails.json" {
18+
resp := gemResponse{
19+
Name: "rails",
20+
Version: "7.1.0",
21+
Info: "Ruby on Rails is a full-stack web framework",
22+
Licenses: []string{"MIT"},
23+
SourceCodeURI: "https://github.com/rails/rails",
24+
HomepageURI: "https://rubyonrails.org",
25+
Authors: "David Heinemeier Hansson",
26+
Downloads: 500000000,
27+
Dependencies: dependenciesResponse{
28+
Runtime: []dependencyInfo{
29+
{Name: "activesupport", Requirements: "= 7.1.0"},
30+
{Name: "actionpack", Requirements: "= 7.1.0"},
31+
},
32+
Development: []dependencyInfo{
33+
{Name: "rake", Requirements: ">= 0"},
34+
},
35+
},
36+
}
37+
json.NewEncoder(w).Encode(resp)
38+
} else {
39+
http.NotFound(w, r)
40+
}
41+
}))
42+
defer server.Close()
43+
44+
c, _ := NewClient(time.Hour)
45+
c.HTTP = server.Client()
46+
c.baseURL = server.URL + "/api/v1"
47+
48+
info, err := c.FetchGem(context.Background(), "rails", false)
49+
if err != nil {
50+
t.Fatalf("FetchGem failed: %v", err)
51+
}
52+
53+
if info.Name != "rails" {
54+
t.Errorf("expected name rails, got %s", info.Name)
55+
}
56+
if info.Version != "7.1.0" {
57+
t.Errorf("expected version 7.1.0, got %s", info.Version)
58+
}
59+
if len(info.Dependencies) != 2 {
60+
t.Errorf("expected 2 runtime dependencies, got %d", len(info.Dependencies))
61+
}
62+
if info.License != "MIT" {
63+
t.Errorf("expected license MIT, got %s", info.License)
64+
}
65+
}
66+
67+
func TestClient_FetchGem_NotFound(t *testing.T) {
68+
server := httptest.NewServer(http.NotFoundHandler())
69+
defer server.Close()
70+
71+
c, _ := NewClient(time.Hour)
72+
c.HTTP = server.Client()
73+
c.baseURL = server.URL
74+
75+
_, err := c.FetchGem(context.Background(), "missing-gem", false)
76+
if err == nil {
77+
t.Fatal("expected error for missing gem")
78+
}
79+
if !errors.Is(err, integrations.ErrNotFound) {
80+
t.Errorf("expected ErrNotFound, got %v", err)
81+
}
82+
}
83+
84+
func TestExtractDeps_RuntimeOnly(t *testing.T) {
85+
deps := dependenciesResponse{
86+
Runtime: []dependencyInfo{
87+
{Name: "activesupport", Requirements: ">= 0"},
88+
{Name: "actionpack", Requirements: ">= 0"},
89+
},
90+
Development: []dependencyInfo{
91+
{Name: "rake", Requirements: ">= 0"},
92+
{Name: "rspec", Requirements: ">= 0"},
93+
},
94+
}
95+
96+
result := extractDeps(deps)
97+
if len(result) != 2 {
98+
t.Errorf("expected 2 runtime deps, got %d", len(result))
99+
}
100+
101+
// Verify only runtime deps are included
102+
hasRake := false
103+
for _, d := range result {
104+
if d == "rake" || d == "rspec" {
105+
hasRake = true
106+
}
107+
}
108+
if hasRake {
109+
t.Error("expected development dependencies to be excluded")
110+
}
111+
}
112+
113+
func TestNormalizeName(t *testing.T) {
114+
tests := []struct {
115+
input string
116+
expected string
117+
}{
118+
{"Rails", "rails"},
119+
{" ActiveRecord ", "activerecord"},
120+
{"UPPERCASE", "uppercase"},
121+
{"some_gem", "some_gem"},
122+
}
123+
124+
for _, tt := range tests {
125+
t.Run(tt.input, func(t *testing.T) {
126+
result := normalizeName(tt.input)
127+
if result != tt.expected {
128+
t.Errorf("expected %s, got %s", tt.expected, result)
129+
}
130+
})
131+
}
132+
}
133+
134+
func TestJoinLicenses(t *testing.T) {
135+
tests := []struct {
136+
input []string
137+
expected string
138+
}{
139+
{nil, ""},
140+
{[]string{}, ""},
141+
{[]string{"MIT"}, "MIT"},
142+
{[]string{"MIT", "Apache-2.0"}, "MIT, Apache-2.0"},
143+
}
144+
145+
for _, tt := range tests {
146+
result := joinLicenses(tt.input)
147+
if result != tt.expected {
148+
t.Errorf("joinLicenses(%v): expected %s, got %s", tt.input, tt.expected, result)
149+
}
150+
}
151+
}
152+

pkg/source/ruby/ruby.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package ruby
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/matzehuels/stacktower/pkg/dag"
8+
"github.com/matzehuels/stacktower/pkg/integrations/rubygems"
9+
"github.com/matzehuels/stacktower/pkg/source"
10+
)
11+
12+
type Parser struct {
13+
client *rubygems.Client
14+
}
15+
16+
func NewParser(cacheTTL time.Duration) (*Parser, error) {
17+
c, err := rubygems.NewClient(cacheTTL)
18+
if err != nil {
19+
return nil, err
20+
}
21+
return &Parser{client: c}, nil
22+
}
23+
24+
func (p *Parser) Parse(ctx context.Context, gem string, opts source.Options) (*dag.DAG, error) {
25+
return source.Parse(ctx, gem, opts, p.fetch)
26+
}
27+
28+
func (p *Parser) fetch(ctx context.Context, name string, refresh bool) (*gemInfo, error) {
29+
info, err := p.client.FetchGem(ctx, name, refresh)
30+
if err != nil {
31+
return nil, err
32+
}
33+
return &gemInfo{info}, nil
34+
}
35+
36+
type gemInfo struct {
37+
*rubygems.GemInfo
38+
}
39+
40+
func (gi *gemInfo) GetName() string { return gi.Name }
41+
func (gi *gemInfo) GetVersion() string { return gi.Version }
42+
func (gi *gemInfo) GetDependencies() []string { return gi.Dependencies }
43+
44+
func (gi *gemInfo) ToMetadata() map[string]any {
45+
m := map[string]any{"version": gi.Version}
46+
if gi.Description != "" {
47+
m["description"] = gi.Description
48+
}
49+
if gi.License != "" {
50+
m["license"] = gi.License
51+
}
52+
if gi.Authors != "" {
53+
m["author"] = gi.Authors
54+
}
55+
if gi.Downloads > 0 {
56+
m["downloads"] = gi.Downloads
57+
}
58+
return m
59+
}
60+
61+
func (gi *gemInfo) ToRepoInfo() *source.RepoInfo {
62+
urls := make(map[string]string, 2)
63+
if gi.SourceCodeURI != "" {
64+
urls["repository"] = gi.SourceCodeURI
65+
}
66+
if gi.HomepageURI != "" {
67+
urls["homepage"] = gi.HomepageURI
68+
}
69+
return &source.RepoInfo{
70+
Name: gi.Name,
71+
Version: gi.Version,
72+
ProjectURLs: urls,
73+
HomePage: gi.HomepageURI,
74+
ManifestFile: "Gemfile",
75+
}
76+
}
77+

pkg/source/ruby/ruby_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package ruby
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestNewParser(t *testing.T) {
9+
p, err := NewParser(time.Hour)
10+
if err != nil {
11+
t.Fatalf("NewParser failed: %v", err)
12+
}
13+
if p.client == nil {
14+
t.Error("client not initialized")
15+
}
16+
}
17+

0 commit comments

Comments
 (0)