From dce138339d9ddab02365e10b36a4e57bf5c1286b Mon Sep 17 00:00:00 2001 From: Regis David Souza Mesquita Date: Sat, 7 Dec 2024 11:08:15 +0000 Subject: [PATCH] Initial Commit --- .gitignore | 23 +++ Makefile | 127 ++++++++++++++++ README.md | 131 ++++++++++++++++ adapters/serper_test.go | 67 -------- adapters/tavily_test.go | 116 -------------- cmd/search/main.go | 14 +- go.mod | 2 +- {adapters => internal/adapters}/serper.go | 10 +- internal/adapters/serper_test.go | 89 +++++++++++ {adapters => internal/adapters}/tavily.go | 12 +- internal/adapters/tavily_test.go | 143 ++++++++++++++++++ internal/adapters/testing_helpers.go | 30 ++++ {adapters => internal/adapters}/types.go | 6 + internal/formatter/formatter.go | 2 +- .../formatter/formatter_test.go | 8 +- main.go | 115 -------------- search.py | 56 ------- 17 files changed, 577 insertions(+), 374 deletions(-) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md delete mode 100644 adapters/serper_test.go delete mode 100644 adapters/tavily_test.go rename {adapters => internal/adapters}/serper.go (88%) create mode 100644 internal/adapters/serper_test.go rename {adapters => internal/adapters}/tavily.go (89%) create mode 100644 internal/adapters/tavily_test.go create mode 100644 internal/adapters/testing_helpers.go rename {adapters => internal/adapters}/types.go (90%) rename main_test.go => internal/formatter/formatter_test.go (92%) delete mode 100644 main.go delete mode 100644 search.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a798a8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# IDE +.vscode/ +.idea/ + +# Build +bin/ +search + +# Test coverage +coverage/ + +# Environment variables +.env + +# Go specific +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..16ec194 --- /dev/null +++ b/Makefile @@ -0,0 +1,127 @@ +.PHONY: build clean test test-coverage test-verbose install lint run setup help local-install + +# Build variables +BINARY_NAME=search +BUILD_DIR=bin +CMD_DIR=cmd/search +COVERAGE_DIR=coverage +GO_BIN=$(HOME)/go/bin + +# Go commands +GOCMD=go +GOBUILD=$(GOCMD) build +GOCLEAN=$(GOCMD) clean +GOTEST=$(GOCMD) test +GOGET=$(GOCMD) get +GOMOD=$(GOCMD) mod +GOLINT=golangci-lint + +# Build the project +build: + @echo "Building..." + @mkdir -p $(BUILD_DIR) + $(GOBUILD) -o $(BUILD_DIR)/$(BINARY_NAME) ./$(CMD_DIR) + +# Clean build files +clean: + @echo "Cleaning..." + @rm -rf $(BUILD_DIR) $(COVERAGE_DIR) + $(GOCLEAN) + +# Run all tests +test: + @echo "Running tests..." + $(GOTEST) ./... + +# Run tests with coverage +test-coverage: + @echo "Running tests with coverage..." + @mkdir -p $(COVERAGE_DIR) + $(GOTEST) -coverprofile=$(COVERAGE_DIR)/coverage.out ./... + $(GOCMD) tool cover -html=$(COVERAGE_DIR)/coverage.out -o $(COVERAGE_DIR)/coverage.html + @echo "Coverage report generated in $(COVERAGE_DIR)/coverage.html" + +# Run tests with verbose output +test-verbose: + @echo "Running tests (verbose)..." + $(GOTEST) -v ./... + +# Install dependencies +deps: + @echo "Downloading dependencies..." + $(GOMOD) download + $(GOMOD) tidy + +# Run linter +lint: + @echo "Running linter..." + $(GOLINT) run + +# Install the binary +install: + @echo "Installing to $(GO_BIN)..." + @mkdir -p $(GO_BIN) + $(GOBUILD) -o $(GO_BIN)/$(BINARY_NAME) ./$(CMD_DIR) + @if ! echo "$$PATH" | grep -q "$(GO_BIN)"; then \ + echo "Warning: $(GO_BIN) is not in your PATH"; \ + echo "Add this line to your ~/.bashrc or ~/.zshrc:"; \ + echo " export PATH=\$$PATH:$(GO_BIN)"; \ + fi + +# Install to /usr/local/bin (requires sudo) +local-install: build + @echo "Installing to /usr/local/bin..." + @sudo install -m 755 $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/$(BINARY_NAME) + +# Run the application (requires query argument) +run: + @if [ -z "$(query)" ]; then \ + echo "Usage: make run query='your search query'"; \ + exit 1; \ + fi + @$(BUILD_DIR)/$(BINARY_NAME) $(query) + +# Setup project structure +setup: + @echo "Setting up project structure..." + @mkdir -p cmd/search internal/adapters internal/formatter + @if [ -f main.go ]; then \ + echo "Moving main.go to cmd/search/..."; \ + rm cmd/search/main.go || true; \ + mv main.go cmd/search/; \ + fi + @if [ -d adapters ]; then \ + echo "Moving adapter files to internal/adapters/..."; \ + mv adapters/* internal/adapters/ 2>/dev/null || true; \ + rmdir adapters; \ + fi + @if [ -f main_test.go ]; then \ + echo "Moving main_test.go to internal/formatter/formatter_test.go..."; \ + mv main_test.go internal/formatter/formatter_test.go; \ + fi + @if [ -f search.py ]; then \ + echo "Removing Python version..."; \ + rm search.py; \ + fi + @echo "Updating go.mod..." + @$(GOMOD) init github.com/regismesquita/search-cli || true + @$(GOMOD) tidy + @echo "Project structure setup complete" + +# Help +help: + @echo "Available commands:" + @echo " make build - Build the project" + @echo " make clean - Clean build files" + @echo " make test - Run tests" + @echo " make test-coverage - Run tests with coverage report" + @echo " make test-verbose - Run tests with verbose output" + @echo " make deps - Download dependencies" + @echo " make lint - Run linter" + @echo " make install - Install binary to ~/go/bin" + @echo " make local-install - Install binary to /usr/local/bin" + @echo " make run - Run the application (requires query='your search query')" + @echo " make setup - Setup project structure" + +# Default target +.DEFAULT_GOAL := help \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e4bf06 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# Search CLI + +A command-line interface for searching using Serper and Tavily APIs. + +## Features + +- Search using Google (via Serper API) +- Search and extract content using Tavily API +- JSON output support +- Configurable search depth (for Tavily) + +## Installation + +### Using Go Install +```bash +go install github.com/regismesquita/search-cli/cmd/search@latest +``` + +### Using mise (formerly rtx) +```bash +mise use -g go@latest +mise install github.com/regismesquita/search-cli/cmd/search@latest +``` + +### From Source + +```bash +# Clone the repository +git clone https://github.com/regismesquita/search-cli +cd search-cli + +# Build and install +make build +make install # Installs to ~/go/bin +# or +make local-install # Installs to /usr/local/bin (requires sudo) +``` + +## Configuration + +Set your API keys as environment variables: +```bash +# For Serper +export SERPER_API_KEY=your_key_here + +# For Tavily +export TAVILY_API_KEY=your_key_here +``` + +## Usage + +### Basic Search (using Serper) +```bash +# Default search using Serper +search "your query" +# or explicitly +search -s "your query" +``` + +### Using Tavily +```bash +# Basic search +search -t "your query" + +# Advanced search +search -t -depth advanced "your query" + +# Extract content from URLs +search -t -e "https://example.com" +``` + +### JSON Output +```bash +search -json "your query" +search -t -json "your query" +search -t -e -json "https://example.com" +``` + +## Options + +- `-s`: Use Serper (default) +- `-t`: Use Tavily +- `-e`: Extract content (Tavily only) +- `-json`: Output in JSON format +- `-depth`: Search depth for Tavily (basic or advanced) + +## Development + +```bash +# Run tests +make test + +# Run tests with coverage +make test-coverage + +# Run linter +make lint + +# Clean build artifacts +make clean +``` + +## Project Structure + +``` +. +├── cmd/ +│ └── search/ # Main application +│ └── main.go +├── internal/ +│ ├── adapters/ # API providers +│ │ ├── serper.go +│ │ ├── tavily.go +│ │ └── types.go +│ └── formatter/ # Output formatting +│ └── formatter.go +├── go.mod +└── Makefile +``` + +## License + +MIT + +## Contributing + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -am 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request \ No newline at end of file diff --git a/adapters/serper_test.go b/adapters/serper_test.go deleted file mode 100644 index 0b1dabe..0000000 --- a/adapters/serper_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package adapters - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -func TestSerperProvider_Search(t *testing.T) { - // Mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify request - if r.Method != "POST" { - t.Errorf("Expected POST request, got %s", r.Method) - } - if r.Header.Get("X-API-KEY") != "test-key" { - t.Errorf("Expected API key header, got %s", r.Header.Get("X-API-KEY")) - } - - // Return mock response - response := map[string]interface{}{ - "organic": []map[string]interface{}{ - { - "link": "https://example.com", - "snippet": "Example content", - }, - }, - } - json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - // Set test environment - os.Setenv("SERPER_API_KEY", "test-key") - defer os.Unsetenv("SERPER_API_KEY") - - provider, err := NewSerperProvider() - if err != nil { - t.Fatalf("Failed to create provider: %v", err) - } - - // Override API URL for testing - originalURL := "https://google.serper.dev/search" - http.DefaultClient = server.Client() - - results, err := provider.Search("test query", nil) - if err != nil { - t.Fatalf("Search failed: %v", err) - } - - if len(results.Results) != 1 { - t.Errorf("Expected 1 result, got %d", len(results.Results)) - } - - if results.Results[0].URL != "https://example.com" { - t.Errorf("Expected URL https://example.com, got %s", results.Results[0].URL) - } - - if results.Results[0].Content != "Example content" { - t.Errorf("Expected content 'Example content', got %s", results.Results[0].Content) - } - - // Restore original URL - _ = originalURL -} diff --git a/adapters/tavily_test.go b/adapters/tavily_test.go deleted file mode 100644 index eb7730f..0000000 --- a/adapters/tavily_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package adapters - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" -) - -func TestTavilyProvider_Search(t *testing.T) { - // Mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify request - if r.Method != "POST" { - t.Errorf("Expected POST request, got %s", r.Method) - } - - // Verify request body contains API key - var reqBody map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { - t.Fatalf("Failed to decode request body: %v", err) - } - if reqBody["api_key"] != "test-key" { - t.Errorf("Expected API key in body, got %v", reqBody["api_key"]) - } - - // Return mock response - response := map[string]interface{}{ - "results": []map[string]interface{}{ - { - "url": "https://example.com", - "content": "Example content", - }, - }, - } - json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - // Set test environment - os.Setenv("TAVILY_API_KEY", "test-key") - defer os.Unsetenv("TAVILY_API_KEY") - - provider, err := NewTavilyProvider() - if err != nil { - t.Fatalf("Failed to create provider: %v", err) - } - - // Override API URL for testing - http.DefaultClient = server.Client() - - results, err := provider.Search("test query", map[string]string{"depth": "advanced"}) - if err != nil { - t.Fatalf("Search failed: %v", err) - } - - if len(results.Results) != 1 { - t.Errorf("Expected 1 result, got %d", len(results.Results)) - } - - if results.Results[0].URL != "https://example.com" { - t.Errorf("Expected URL https://example.com, got %s", results.Results[0].URL) - } - - if results.Results[0].Content != "Example content" { - t.Errorf("Expected content 'Example content', got %s", results.Results[0].Content) - } -} - -func TestTavilyProvider_Extract(t *testing.T) { - // Mock server - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify request - if r.Method != "POST" { - t.Errorf("Expected POST request, got %s", r.Method) - } - - // Return mock response - response := ExtractResponse{ - Results: []ExtractResult{ - { - URL: "https://example.com", - RawContent: "Example content", - }, - }, - } - json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - // Set test environment - os.Setenv("TAVILY_API_KEY", "test-key") - defer os.Unsetenv("TAVILY_API_KEY") - - provider, err := NewTavilyProvider() - if err != nil { - t.Fatalf("Failed to create provider: %v", err) - } - - // Override API URL for testing - http.DefaultClient = server.Client() - - results, err := provider.Extract([]string{"https://example.com"}) - if err != nil { - t.Fatalf("Extract failed: %v", err) - } - - if len(results.Results) != 1 { - t.Errorf("Expected 1 result, got %d", len(results.Results)) - } - - if results.Results[0].URL != "https://example.com" { - t.Errorf("Expected URL https://example.com, got %s", results.Results[0].URL) - } -} diff --git a/cmd/search/main.go b/cmd/search/main.go index cf50b13..a89f758 100644 --- a/cmd/search/main.go +++ b/cmd/search/main.go @@ -7,15 +7,15 @@ import ( "os" "strings" - "search-cli/internal/adapters" - "search-cli/internal/formatter" + "github.com/regismesquita/search-cli/internal/adapters" + "github.com/regismesquita/search-cli/internal/formatter" ) func main() { jsonOutput := flag.Bool("json", false, "Output in JSON format") - tavilyMode := flag.Bool("tavily", false, "Use Tavily as provider") - serperMode := flag.Bool("serper", false, "Use Serper as provider (default)") - extractMode := flag.Bool("extract", false, "Extract content from URLs (Tavily only)") + tavilyMode := flag.Bool("t", false, "Use Tavily as provider") + serperMode := flag.Bool("s", false, "Use Serper as provider (default)") + extractMode := flag.Bool("e", false, "Extract content from URLs (Tavily only)") depth := flag.String("depth", "basic", "Search depth (basic or advanced) - only for Tavily") flag.Parse() @@ -26,13 +26,13 @@ func main() { // Validate provider flags if *tavilyMode && *serperMode { - fmt.Fprintln(os.Stderr, "Error: Cannot use both -tavily and -serper") + fmt.Fprintln(os.Stderr, "Error: Cannot use both -t and -s") os.Exit(1) } // Validate extract mode if *extractMode && *serperMode { - fmt.Fprintln(os.Stderr, "Error: Extract mode is only available with Tavily") + fmt.Fprintln(os.Stderr, "Error: Extract mode (-e) is only available with Tavily (-t)") os.Exit(1) } diff --git a/go.mod b/go.mod index f504e8e..0e9064b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module search-cli +module github.com/regismesquita/search-cli go 1.21 diff --git a/adapters/serper.go b/internal/adapters/serper.go similarity index 88% rename from adapters/serper.go rename to internal/adapters/serper.go index 31f961c..81e6c95 100644 --- a/adapters/serper.go +++ b/internal/adapters/serper.go @@ -10,7 +10,8 @@ import ( ) type SerperProvider struct { - apiKey string + apiKey string + baseURL string } type serperRequest struct { @@ -29,7 +30,10 @@ func NewSerperProvider() (*SerperProvider, error) { if apiKey == "" { return nil, fmt.Errorf("SERPER_API_KEY environment variable is not set") } - return &SerperProvider{apiKey: apiKey}, nil + return &SerperProvider{ + apiKey: apiKey, + baseURL: "https://google.serper.dev/search", + }, nil } func (s *SerperProvider) Search(query string, options map[string]string) (*SearchResponse, error) { @@ -39,7 +43,7 @@ func (s *SerperProvider) Search(query string, options map[string]string) (*Searc return nil, err } - req, err := http.NewRequest("POST", "https://google.serper.dev/search", bytes.NewBuffer(jsonData)) + req, err := http.NewRequest("POST", s.baseURL, bytes.NewBuffer(jsonData)) if err != nil { return nil, err } diff --git a/internal/adapters/serper_test.go b/internal/adapters/serper_test.go new file mode 100644 index 0000000..3341c17 --- /dev/null +++ b/internal/adapters/serper_test.go @@ -0,0 +1,89 @@ +package adapters + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func setupMockServer(t *testing.T, expectedResponse interface{}) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.Header.Get("X-API-KEY") != "test-key" { + t.Errorf("Expected API key header, got %s", r.Header.Get("X-API-KEY")) + } + + json.NewEncoder(w).Encode(expectedResponse) + })) +} + +func TestSerperProvider_Search(t *testing.T) { + mockServer := NewTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.Header.Get("X-API-KEY") != "test-key" { + t.Errorf("Expected API key header, got %s", r.Header.Get("X-API-KEY")) + } + + // Return mock response + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "organic": [ + { + "link": "https://example.com", + "snippet": "Example content" + } + ] + }`)) + })) + defer mockServer.Close() + + // Set test environment + oldKey := os.Getenv("SERPER_API_KEY") + os.Setenv("SERPER_API_KEY", "test-key") + defer os.Setenv("SERPER_API_KEY", oldKey) + + provider, err := NewSerperProvider() + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + provider.baseURL = mockServer.URL + + results, err := provider.Search("test query", nil) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + + // Verify results + if len(results.Results) != 1 { + t.Errorf("Expected 1 result, got %d", len(results.Results)) + } + + expected := SearchResult{ + URL: "https://example.com", + Content: "Example content", + } + + if results.Results[0] != expected { + t.Errorf("Expected %+v, got %+v", expected, results.Results[0]) + } +} + +func TestSerperProvider_NoAPIKey(t *testing.T) { + // Clear API key + oldKey := os.Getenv("SERPER_API_KEY") + os.Unsetenv("SERPER_API_KEY") + defer os.Setenv("SERPER_API_KEY", oldKey) + + _, err := NewSerperProvider() + if err == nil { + t.Error("Expected error when no API key is set") + } +} diff --git a/adapters/tavily.go b/internal/adapters/tavily.go similarity index 89% rename from adapters/tavily.go rename to internal/adapters/tavily.go index 8d8c01b..7bf67d6 100644 --- a/adapters/tavily.go +++ b/internal/adapters/tavily.go @@ -10,7 +10,8 @@ import ( ) type TavilyProvider struct { - apiKey string + apiKey string + baseURL string } func NewTavilyProvider() (*TavilyProvider, error) { @@ -18,7 +19,10 @@ func NewTavilyProvider() (*TavilyProvider, error) { if apiKey == "" { return nil, fmt.Errorf("TAVILY_API_KEY environment variable is not set") } - return &TavilyProvider{apiKey: apiKey}, nil + return &TavilyProvider{ + apiKey: apiKey, + baseURL: "https://api.tavily.com", // default URL + }, nil } func (t *TavilyProvider) Extract(urls []string) (*ExtractResponse, error) { @@ -35,7 +39,7 @@ func (t *TavilyProvider) Extract(urls []string) (*ExtractResponse, error) { return nil, err } - req, err := http.NewRequest("POST", "https://api.tavily.com/extract", bytes.NewBuffer(jsonData)) + req, err := http.NewRequest("POST", t.baseURL+"/extract", bytes.NewBuffer(jsonData)) if err != nil { return nil, err } @@ -87,7 +91,7 @@ func (t *TavilyProvider) Search(query string, options map[string]string) (*Searc return nil, err } - req, err := http.NewRequest("POST", "https://api.tavily.com/search", bytes.NewBuffer(jsonData)) + req, err := http.NewRequest("POST", t.baseURL+"/search", bytes.NewBuffer(jsonData)) if err != nil { return nil, err } diff --git a/internal/adapters/tavily_test.go b/internal/adapters/tavily_test.go new file mode 100644 index 0000000..8130eb4 --- /dev/null +++ b/internal/adapters/tavily_test.go @@ -0,0 +1,143 @@ +package adapters + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func setupTavilyMockServer(t *testing.T, expectedResponse interface{}) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + + // Verify request body contains API key + var reqBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatalf("Failed to decode request body: %v", err) + } + if reqBody["api_key"] != "test-key" { + t.Errorf("Expected API key in body, got %v", reqBody["api_key"]) + } + + json.NewEncoder(w).Encode(expectedResponse) + })) +} + +func TestTavilyProvider_Search(t *testing.T) { + mockServer := NewTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + + // Return mock response + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "results": [ + { + "url": "https://example.com", + "content": "Example content" + } + ] + }`)) + })) + defer mockServer.Close() + + // Set test environment + oldKey := os.Getenv("TAVILY_API_KEY") + os.Setenv("TAVILY_API_KEY", "test-key") + defer os.Setenv("TAVILY_API_KEY", oldKey) + + provider, err := NewTavilyProvider() + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + provider.baseURL = mockServer.URL // Set mock server URL + + results, err := provider.Search("test query", map[string]string{"depth": "advanced"}) + if err != nil { + t.Fatalf("Search failed: %v", err) + } + + // Verify results + if len(results.Results) != 1 { + t.Errorf("Expected 1 result, got %d", len(results.Results)) + } + + expected := SearchResult{ + URL: "https://example.com", + Content: "Example content", + } + + if results.Results[0] != expected { + t.Errorf("Expected %+v, got %+v", expected, results.Results[0]) + } +} + +func TestTavilyProvider_Extract(t *testing.T) { + mockServer := NewTestServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + + // Return mock response + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "results": [ + { + "url": "https://example.com", + "raw_content": "Example content" + } + ] + }`)) + })) + defer mockServer.Close() + + // Set test environment + oldKey := os.Getenv("TAVILY_API_KEY") + os.Setenv("TAVILY_API_KEY", "test-key") + defer os.Setenv("TAVILY_API_KEY", oldKey) + + provider, err := NewTavilyProvider() + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + provider.baseURL = mockServer.URL // Set mock server URL + + results, err := provider.Extract([]string{"https://example.com"}) + if err != nil { + t.Fatalf("Extract failed: %v", err) + } + + // Verify results + if len(results.Results) != 1 { + t.Errorf("Expected 1 result, got %d", len(results.Results)) + } + + expected := ExtractResult{ + URL: "https://example.com", + RawContent: "Example content", + } + + if results.Results[0] != expected { + t.Errorf("Expected %+v, got %+v", expected, results.Results[0]) + } +} + +func TestTavilyProvider_NoAPIKey(t *testing.T) { + // Clear API key + oldKey := os.Getenv("TAVILY_API_KEY") + os.Unsetenv("TAVILY_API_KEY") + defer os.Setenv("TAVILY_API_KEY", oldKey) + + _, err := NewTavilyProvider() + if err == nil { + t.Error("Expected error when no API key is set") + } +} diff --git a/internal/adapters/testing_helpers.go b/internal/adapters/testing_helpers.go new file mode 100644 index 0000000..3f8a949 --- /dev/null +++ b/internal/adapters/testing_helpers.go @@ -0,0 +1,30 @@ +package adapters + +import ( + "net/http" + "net/http/httptest" +) + +// TestServer represents a mock server for testing +type TestServer struct { + *httptest.Server + OriginalClient *http.Client +} + +// NewTestServer creates a new test server and patches the default HTTP client +func NewTestServer(handler http.HandlerFunc) *TestServer { + server := httptest.NewServer(handler) + originalClient := http.DefaultClient + http.DefaultClient = server.Client() + + return &TestServer{ + Server: server, + OriginalClient: originalClient, + } +} + +// Close closes the server and restores the original HTTP client +func (s *TestServer) Close() { + http.DefaultClient = s.OriginalClient + s.Server.Close() +} diff --git a/adapters/types.go b/internal/adapters/types.go similarity index 90% rename from adapters/types.go rename to internal/adapters/types.go index d357dca..e55b6a7 100644 --- a/adapters/types.go +++ b/internal/adapters/types.go @@ -34,3 +34,9 @@ type ExtractResponse struct { FailedResults []FailedResult `json:"failed_results"` ResponseTime float64 `json:"response_time"` } + +// Add base URL configuration +type Config struct { + APIKey string + BaseURL string +} diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go index 6945527..f2e591b 100644 --- a/internal/formatter/formatter.go +++ b/internal/formatter/formatter.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "search-cli/internal/adapters" + "github.com/regismesquita/search-cli/internal/adapters" ) func FormatResults(results *adapters.SearchResponse) { diff --git a/main_test.go b/internal/formatter/formatter_test.go similarity index 92% rename from main_test.go rename to internal/formatter/formatter_test.go index 15ddab6..28debd1 100644 --- a/main_test.go +++ b/internal/formatter/formatter_test.go @@ -1,4 +1,4 @@ -package main +package formatter import ( "bytes" @@ -6,7 +6,7 @@ import ( "strings" "testing" - "search-cli/adapters" + "github.com/regismesquita/search-cli/internal/adapters" ) func TestFormatResults(t *testing.T) { @@ -24,7 +24,7 @@ func TestFormatResults(t *testing.T) { r, w, _ := os.Pipe() os.Stdout = w - formatResults(results) + FormatResults(results) w.Close() os.Stdout = old @@ -62,7 +62,7 @@ func TestFormatExtractResults(t *testing.T) { r, w, _ := os.Pipe() os.Stdout = w - formatExtractResults(results) + FormatExtractResults(results) w.Close() os.Stdout = old diff --git a/main.go b/main.go deleted file mode 100644 index c127d6e..0000000 --- a/main.go +++ /dev/null @@ -1,115 +0,0 @@ -package main - -import ( - "encoding/json" - "flag" - "fmt" - "os" - "strings" - - "search-cli/adapters" -) - -func formatResults(results *adapters.SearchResponse) { - for _, result := range results.Results { - fmt.Printf("\n🔗 %s\n", result.URL) - fmt.Printf("📝 %s\n", result.Content) - fmt.Println(strings.Repeat("-", 80)) - } -} - -func formatExtractResults(results *adapters.ExtractResponse) { - for _, result := range results.Results { - fmt.Printf("\n🔗 %s\n", result.URL) - fmt.Printf("📄 %s\n", result.RawContent) - fmt.Println(strings.Repeat("-", 80)) - } - - if len(results.FailedResults) > 0 { - fmt.Println("\n❌ Failed URLs:") - for _, failed := range results.FailedResults { - fmt.Printf("URL: %s\nError: %s\n", failed.URL, failed.Error) - fmt.Println(strings.Repeat("-", 40)) - } - } -} - -func main() { - jsonOutput := flag.Bool("json", false, "Output in JSON format") - tavilyMode := flag.Bool("tavily", false, "Use Tavily as provider") - serperMode := flag.Bool("serper", false, "Use Serper as provider (default)") - extractMode := flag.Bool("extract", false, "Extract content from URLs (Tavily only)") - depth := flag.String("depth", "basic", "Search depth (basic or advanced) - only for Tavily") - flag.Parse() - - if flag.NArg() < 1 { - fmt.Fprintln(os.Stderr, "Error: Query or URLs required") - os.Exit(1) - } - - // Validate provider flags - if *tavilyMode && *serperMode { - fmt.Fprintln(os.Stderr, "Error: Cannot use both -tavily and -serper") - os.Exit(1) - } - - // Validate extract mode - if *extractMode && *serperMode { - fmt.Fprintln(os.Stderr, "Error: Extract mode is only available with Tavily") - os.Exit(1) - } - - if *extractMode { - provider, err := adapters.NewTavilyProvider() - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - urls := strings.Fields(strings.Join(flag.Args(), " ")) - results, err := provider.Extract(urls) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - if *jsonOutput { - json.NewEncoder(os.Stdout).Encode(results) - } else { - formatExtractResults(results) - } - } else { - var searchProvider adapters.SearchProvider - var err error - - // Default to Serper unless Tavily is explicitly requested - if *tavilyMode { - searchProvider, err = adapters.NewTavilyProvider() - } else { - searchProvider, err = adapters.NewSerperProvider() - } - - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - options := map[string]string{} - if *tavilyMode { - options["depth"] = *depth - } - - query := flag.Arg(0) - results, err := searchProvider.Search(query, options) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - if *jsonOutput { - json.NewEncoder(os.Stdout).Encode(results) - } else { - formatResults(results) - } - } -} diff --git a/search.py b/search.py deleted file mode 100644 index c91cf99..0000000 --- a/search.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import requests -import json -import sys -import os -from typing import Dict, Any - -TAVILY_API_KEY = os.getenv("TAVILY_API_KEY") -if not TAVILY_API_KEY: - print("Error: TAVILY_API_KEY environment variable is not set", file=sys.stderr) - sys.exit(1) - -API_URL = "https://api.tavily.com/search" - -def search(query: str, search_depth: str = "basic") -> Dict[Any, Any]: - headers = {"api-key": TAVILY_API_KEY} - params = { - "query": query, - "search_depth": search_depth, - "include_domains": [], - "exclude_domains": [] - } - - try: - response = requests.post(API_URL, json=params, headers=headers) - response.raise_for_status() - return response.json() - except requests.exceptions.RequestException as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) - -def format_results(data: Dict[Any, Any]) -> None: - for result in data['results']: - print(f"\n🔗 {result['url']}") - print(f"📝 {result['content']}\n") - print("-" * 80) - -def main(): - parser = argparse.ArgumentParser(description="Tavily Search CLI") - parser.add_argument("query", help="Search query") - parser.add_argument("--json", action="store_true", help="Output in JSON format") - parser.add_argument("--depth", choices=["basic", "advanced"], default="basic", - help="Search depth (basic or advanced)") - - args = parser.parse_args() - - results = search(args.query, args.depth) - - if args.json: - print(json.dumps(results, indent=2)) - else: - format_results(results) - -if __name__ == "__main__": - main() \ No newline at end of file