Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions cmd/lq/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"time"

"github.com/bakayu/lq/internal/config"
"github.com/bakayu/lq/internal/provider"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/huh/spinner"
Expand All @@ -22,6 +23,12 @@ func main() {
)

theme := huh.ThemeCatppuccin()
// Load configuration
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Configuration Error: %v\n", err)
os.Exit(1)
}

form := huh.NewForm(
huh.NewGroup(
Expand All @@ -38,12 +45,11 @@ func main() {
if err := form.Run(); err != nil {
log.Fatal(err)
}

var prov provider.Provider
if fileType == ".gitignore" {
prov = provider.NewGitignoreProvider()
prov = provider.NewGitignoreProvider(cfg.GitignoreListURL, cfg.GitignoreGetURL)
} else {
prov = provider.NewLicenseProvider()
prov = provider.NewLicenseProvider(cfg.LicenseListURL, cfg.LicenseGetURL)
}

var templates []provider.Template
Expand Down
60 changes: 60 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package config

import (
"net/url"
"os"
"strings"
)

type Config struct {
GitignoreListURL string
GitignoreGetURL string
LicenseListURL string
LicenseGetURL string
}

const (
// Default URLs for fetching gitignore and license templates
DefaultGitignoreListURL = "https://www.toptal.com/developers/gitignore/api/list?format=json"
DefaultGitignoreGetURL = "https://www.toptal.com/developers/gitignore/api/%s"
DefaultLicenseListURL = "https://api.github.com/licenses"
DefaultLicenseGetURL = "https://api.github.com/licenses/%s"
)

// Load reads configuration from environment variables, falling back to defaults if not set.
func Load() (*Config, error) {
cfg := &Config{
GitignoreListURL: getEnv("LQ_GITIGNORE_LIST_URL", DefaultGitignoreListURL),
GitignoreGetURL: getEnv("LQ_GITIGNORE_GET_URL", DefaultGitignoreGetURL),
LicenseListURL: getEnv("LQ_LICENSE_LIST_URL", DefaultLicenseListURL),
LicenseGetURL: getEnv("LQ_LICENSE_GET_URL", DefaultLicenseGetURL),
}

if err := cfg.Validate(); err != nil {
return nil, err
}

return cfg, nil
}

func getEnv(key, fallback string) string {
if value, exists := os.LookupEnv(key); exists && value != "" {
return value
}
return fallback
}

// Validate ensures all custom or default URLs are well-formed on startup
func (c *Config) Validate() error {
urls := []string{c.GitignoreListURL, c.GitignoreGetURL, c.LicenseListURL, c.LicenseGetURL}

for _, u := range urls {
testURL := strings.ReplaceAll(u, "%s", "dummy-template")

if _, err := url.ParseRequestURI(testURL); err != nil {
return err
}
}

return nil
}
43 changes: 43 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package config

import (
"testing"
)

func TestLoadConfig_Defaults(t *testing.T) {
// Ensure environment is clean
t.Setenv("LQ_GITIGNORE_LIST_URL", "")

cfg, err := Load()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}

expectedDefault := "https://www.toptal.com/developers/gitignore/api/list?format=json"
if cfg.GitignoreListURL != expectedDefault {
t.Errorf("Expected %s, got %s", expectedDefault, cfg.GitignoreListURL)
}
}

func TestLoadConfig_Overrides(t *testing.T) {
customURL := "https://custom.company.com/api/gitignores"
t.Setenv("LQ_GITIGNORE_LIST_URL", customURL)

cfg, err := Load()
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}

if cfg.GitignoreListURL != customURL {
t.Errorf("Expected overridden URL %s, got %s", customURL, cfg.GitignoreListURL)
}
}

func TestLoadConfig_InvalidURL(t *testing.T) {
t.Setenv("LQ_GITIGNORE_LIST_URL", "not-a-valid-url")

_, err := Load()
if err == nil {
t.Fatal("Expected error for invalid URL, got nil")
}
}
86 changes: 58 additions & 28 deletions internal/provider/gitignore.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,7 @@ import (
"fmt"
"io"
"net/http"
"sort"
)

const (
defaultGitignoreListURL = "https://www.toptal.com/developers/gitignore/api/list?format=json"
defaultGitignoreGetURL = "https://www.toptal.com/developers/gitignore/api/%s"
"net/url"
)

type GitignoreProvider struct {
Expand All @@ -20,11 +15,11 @@ type GitignoreProvider struct {
}

// NewGitignoreProvider returns a provider with a default HTTP client
func NewGitignoreProvider() *GitignoreProvider {
func NewGitignoreProvider(listURL, getURL string) *GitignoreProvider {
return &GitignoreProvider{
Client: http.DefaultClient,
ListURL: defaultGitignoreListURL,
GetURL: defaultGitignoreGetURL,
ListURL: listURL,
GetURL: getURL,
}
}

Expand All @@ -33,7 +28,7 @@ type gitignoreItem struct {
FileName string `json:"fileName"`
}

// List fetches all available gitignore templates
// List fetches all available gitignore templates using a try-and-fallback parsing strategy
func (g *GitignoreProvider) List() ([]Template, error) {
response, err := g.Client.Get(g.ListURL)
if err != nil {
Expand All @@ -45,44 +40,79 @@ func (g *GitignoreProvider) List() ([]Template, error) {
return nil, fmt.Errorf("%w: status %d", ErrFetchFailed, response.StatusCode)
}

// Toptal returns a list of key-name pairs
var rawMap map[string]gitignoreItem
if err := json.NewDecoder(response.Body).Decode(&rawMap); err != nil {
return nil, fmt.Errorf("failed to parse json: %w", err)
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}

var templates []Template
for k, item := range rawMap {
templates = append(templates, Template{
Key: k,
Name: item.Name,
})

// Schema 1: Map Format (e.g., Toptal API)
var mapFormat map[string]gitignoreItem
if err := json.Unmarshal(body, &mapFormat); err == nil && len(mapFormat) > 0 {
for key, val := range mapFormat {
templates = append(templates, Template{Key: key, Name: val.Name})
}
return templates, nil
}

sort.Slice(templates, func(i, j int) bool {
return templates[i].Name < templates[j].Name
})
// Schema 2: Flat String Array Format (e.g., GitHub API)
var stringArrayFormat []string
if err := json.Unmarshal(body, &stringArrayFormat); err == nil && len(stringArrayFormat) > 0 {
for _, name := range stringArrayFormat {
templates = append(templates, Template{Key: name, Name: name})
}
return templates, nil
}

return templates, nil
// Schema 3: Object Array Format (e.g., GitLab API)
var objectArrayFormat []struct {
Key string `json:"key"`
Name string `json:"name"`
}
if err := json.Unmarshal(body, &objectArrayFormat); err == nil && len(objectArrayFormat) > 0 {
for _, val := range objectArrayFormat {
templates = append(templates, Template{Key: val.Key, Name: val.Name})
}
return templates, nil
}

return nil, fmt.Errorf("unsupported API schema returned from %s", g.ListURL)
}

// GetContent fetches the raw text of a specific gitignore template
func (g *GitignoreProvider) GetContent(key string) (string, error) {
requestUrl := fmt.Sprintf(g.GetURL, key)
response, err := g.Client.Get(requestUrl)
escapedKey := url.PathEscape(key)
targetURL := fmt.Sprintf(g.GetURL, escapedKey)

response, err := g.Client.Get(targetURL)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrFetchFailed, err)
return "", fmt.Errorf("failed to fetch content: %w", err)
}
defer func() { _ = response.Body.Close() }()

if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("%w: status %v", ErrFetchFailed, response.StatusCode)
if response.StatusCode < 200 || response.StatusCode >= 300 {
return "", fmt.Errorf("provider returned error status: %s for URL: %s", response.Status, targetURL)
}

body, err := io.ReadAll(response.Body)
if err != nil {
return "", err
}

var jsonResponse struct {
Source string `json:"source"`
Content string `json:"content"`
}

if err := json.Unmarshal(body, &jsonResponse); err == nil {
if jsonResponse.Source != "" {
return jsonResponse.Source, nil
}
if jsonResponse.Content != "" {
return jsonResponse.Content, nil
}
}

return string(body), nil
}
Loading
Loading