diff --git a/cmd/lq/main.go b/cmd/lq/main.go index 76e9faa..cbc9935 100644 --- a/cmd/lq/main.go +++ b/cmd/lq/main.go @@ -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" @@ -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( @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..200f966 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..9fbf110 --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/provider/gitignore.go b/internal/provider/gitignore.go index c4eb286..0268aa9 100644 --- a/internal/provider/gitignore.go +++ b/internal/provider/gitignore.go @@ -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 { @@ -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, } } @@ -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 { @@ -45,38 +40,59 @@ 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) @@ -84,5 +100,19 @@ func (g *GitignoreProvider) GetContent(key string) (string, error) { 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 } diff --git a/internal/provider/license.go b/internal/provider/license.go index 73be13b..9a864cf 100644 --- a/internal/provider/license.go +++ b/internal/provider/license.go @@ -3,83 +3,92 @@ package provider import ( "encoding/json" "fmt" + "io" "net/http" ) -const ( - defaultLicenseListURL = "https://api.github.com/licenses" - defaultLicenseGetURL = "https://api.github.com/licenses/%s" -) - type LicenseProvider struct { Client *http.Client ListURL string GetURL string } -func NewLicenseProvider() *LicenseProvider { +func NewLicenseProvider(listURL, getURL string) *LicenseProvider { return &LicenseProvider{ Client: http.DefaultClient, - ListURL: defaultLicenseListURL, - GetURL: defaultLicenseGetURL, + ListURL: listURL, + GetURL: getURL, } } -type ghLicenseSimple struct { - Key string `json:"key"` - Name string `json:"name"` -} - -type ghLicenseDetail struct { - Body string `json:"body"` -} - +// List fetches all available license templates func (l *LicenseProvider) List() ([]Template, error) { - req, _ := http.NewRequest("GET", l.ListURL, nil) - req.Header.Set("Accept", "application/vnd.github.v3+json") - - resp, err := l.Client.Do(req) + response, err := l.Client.Get(l.ListURL) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrFetchFailed, err) + return nil, fmt.Errorf("failed to fetch license templates: %w", err) } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%w: status %d", ErrFetchFailed, resp.StatusCode) + defer func() { + err := response.Body.Close() + if err != nil { + fmt.Printf("failed to close response body: %v\n", err) + } + }() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err } - var ghList []ghLicenseSimple - if err := json.NewDecoder(resp.Body).Decode(&ghList); err != nil { - return nil, fmt.Errorf("failed to parse json: %w", err) + var templates []Template + + // Standard Schema: Array of objects (Used by both GitHub and GitLab) + var standardSchema []struct { + Key string `json:"key"` + Name string `json:"name"` } - var templates []Template - for _, item := range ghList { - templates = append(templates, Template(item)) + if err := json.Unmarshal(body, &standardSchema); err == nil && len(standardSchema) > 0 { + for _, val := range standardSchema { + templates = append(templates, Template{Key: val.Key, Name: val.Name}) + } + return templates, nil } - return templates, nil + return nil, fmt.Errorf("unsupported API schema returned from %s", l.ListURL) } +// GetContent fetches the content of a specific license template func (l *LicenseProvider) GetContent(key string) (string, error) { url := fmt.Sprintf(l.GetURL, key) - req, _ := http.NewRequest("GET", url, nil) - req.Header.Set("Accept", "application/vnd.github.v3+json") - - resp, err := l.Client.Do(req) + response, err := l.Client.Get(url) + if err != nil { + return "", fmt.Errorf("failed to fetch license content: %w", err) + } + defer func() { + err := response.Body.Close() + if err != nil { + fmt.Printf("failed to close response body: %v\n", err) + } + }() + + body, err := io.ReadAll(response.Body) if err != nil { - return "", fmt.Errorf("%w: %v", ErrFetchFailed, err) + return "", err } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("%w: status %d", ErrFetchFailed, resp.StatusCode) + var jsonResponse struct { + Body string `json:"body"` // GitHub schema + Content string `json:"content"` // GitLab schema } - var detail ghLicenseDetail - if err := json.NewDecoder(resp.Body).Decode(&detail); err != nil { - return "", fmt.Errorf("failed to parse json: %w", err) + if err := json.Unmarshal(body, &jsonResponse); err == nil { + if jsonResponse.Body != "" { + return jsonResponse.Body, nil + } + if jsonResponse.Content != "" { + return jsonResponse.Content, nil + } } - return detail.Body, nil + return string(body), nil } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index a5c9aec..b9dea61 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -16,8 +16,7 @@ func TestGitignoreList(t *testing.T) { defer server.Close() // 2. Configure provider to use the mock URL - p := NewGitignoreProvider() - p.ListURL = server.URL // Override the URL + p := NewGitignoreProvider(server.URL, "http://dummy-fallback-get") // 3. Test list, err := p.List() @@ -41,9 +40,7 @@ func TestLicenseGetContent(t *testing.T) { })) defer server.Close() - // 2. Configure - p := NewLicenseProvider() - p.GetURL = server.URL + "/%s" + p := NewLicenseProvider("http://dummy-fallback-list", server.URL+"/%s") // 3. Test content, err := p.GetContent("mit")