1package cli
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "net/url"
8 "os"
9 "os/exec"
10 "regexp"
11 "strings"
12
13 "github.com/bnema/dumber/internal/config"
14
15 "github.com/spf13/cobra"
16)
17
18// NewBrowseCmd creates the browse command
19func NewBrowseCmd() *cobra.Command {
20 var renderingMode string
21
22 cmd := &cobra.Command{
23 Use: "browse <url|query>",
24 Short: "Browse a URL or search query",
25 Long: `Browse a URL directly or use search shortcuts like:
26 g:golang -> Google search for "golang"
27 gh:cobra -> GitHub search for "cobra"
28 yt:tutorials -> YouTube search for "tutorials"
29 w:go language -> Wikipedia search for "go language"
30
31Direct URLs are also supported:
32 https://example.com
33 example.com (automatically adds https://)`,
34 Args: cobra.ExactArgs(1),
35 RunE: func(_ *cobra.Command, args []string) error {
36 // Propagate rendering mode to GUI process via env var
37 if renderingMode != "" {
38 rm := strings.ToLower(renderingMode)
39 switch rm {
40 case "auto", "gpu", "cpu":
41 _ = os.Setenv("DUMBER_RENDERING_MODE", rm)
42 default:
43 return fmt.Errorf("invalid --rendering-mode: %s (expected auto|gpu|cpu)", renderingMode)
44 }
45 }
46 cli, err := NewCLI()
47 if err != nil {
48 return fmt.Errorf("failed to initialize CLI: %w", err)
49 }
50 defer func() {
51 if closeErr := cli.Close(); closeErr != nil {
52 fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
53 }
54 }()
55
56 return browse(cli, args[0])
57 },
58 }
59
60 // Flags
61 cmd.Flags().StringVar(&renderingMode, "rendering-mode", "", "Rendering mode: auto|gpu|cpu")
62
63 return cmd
64}
65
66// browse handles the core browsing logic
67func browse(cli *CLI, input string) error {
68 ctx := context.Background()
69
70 // Parse the input to determine URL or shortcut
71 finalURL, err := parseInput(ctx, cli, input)
72 if err != nil {
73 return fmt.Errorf("failed to parse input: %w", err)
74 }
75
76 // Record in history
77 if err := recordVisit(ctx, cli, finalURL, ""); err != nil {
78 // Don't fail the browse operation if history recording fails
79 fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err)
80 }
81
82 // Open URL using configuration
83 return openURLWithConfig(finalURL, cli.Config)
84}
85
86// parseInput processes user input and returns the final URL to browse
87func parseInput(ctx context.Context, cli *CLI, input string) (string, error) {
88 // Check if it's a shortcut (format: "prefix:query")
89 if strings.Contains(input, ":") && !strings.Contains(input, "://") {
90 parts := strings.SplitN(input, ":", 2) //nolint:mnd // split on first colon only
91 if len(parts) == 2 { //nolint:mnd // expect prefix and query parts
92 shortcut := strings.TrimSpace(parts[0])
93 query := strings.TrimSpace(parts[1])
94
95 if query == "" {
96 return "", fmt.Errorf("empty query for shortcut '%s'", shortcut)
97 }
98
99 // First check configuration-based shortcuts
100 if shortcutCfg, exists := cli.Config.SearchShortcuts[shortcut]; exists {
101 return fmt.Sprintf(shortcutCfg.URL, url.QueryEscape(query)), nil
102 }
103
104 // Fallback to database shortcuts for backward compatibility
105 shortcuts, err := cli.Queries.GetShortcuts(ctx)
106 if err != nil {
107 return "", fmt.Errorf("failed to get shortcuts: %w", err)
108 }
109
110 for _, s := range shortcuts {
111 if s.Shortcut == shortcut {
112 return fmt.Sprintf(s.UrlTemplate, url.QueryEscape(query)), nil
113 }
114 }
115
116 return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
117 }
118 }
119
120 // Check if it's already a valid URL
121 if isValidURL(input) {
122 return input, nil
123 }
124
125 // Try to make it a URL by adding https://
126 if !strings.Contains(input, "://") {
127 candidate := "https://" + input
128 if isValidURL(candidate) {
129 return candidate, nil
130 }
131 }
132
133 // If all else fails, search Google for it
134 return fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(input)), nil
135}
136
137// isValidURL checks if a string is a valid URL
138func isValidURL(str string) bool {
139 u, err := url.Parse(str)
140 if err != nil {
141 return false
142 }
143
144 // Must have a scheme and host
145 if u.Scheme == "" || u.Host == "" {
146 return false
147 }
148
149 // Must be http or https
150 if u.Scheme != "http" && u.Scheme != "https" {
151 return false
152 }
153
154 // Basic domain validation
155 domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?` +
156 `(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
157 return domainRegex.MatchString(u.Host)
158}
159
160// recordVisit adds or updates a URL visit in the history
161func recordVisit(ctx context.Context, cli *CLI, url, title string) error {
162 var sqlTitle sql.NullString
163 if title != "" {
164 sqlTitle = sql.NullString{String: title, Valid: true}
165 }
166
167 return cli.Queries.AddOrUpdateHistory(ctx, url, sqlTitle)
168}
169
170// openURL opens a URL using the configured browser
171func openURL(url string) error {
172 return openURLWithConfig(url, nil)
173}
174
175// openURLWithConfig opens a URL using our built-in browser
176func openURLWithConfig(url string, cfg *config.Config) error {
177 // Get the path to our own executable
178 executable, err := os.Executable()
179 if err != nil {
180 return fmt.Errorf("failed to get executable path: %w", err)
181 }
182
183 // Launch our own browser in browse mode with the URL directly
184 cmd := exec.Command(executable, "browse", url)
185
186 // Start the browser in detached mode
187 if err := cmd.Start(); err != nil {
188 return fmt.Errorf("failed to open URL in built-in browser: %w", err)
189 }
190
191 // Don't wait for the browser process to exit
192 go func() {
193 _ = cmd.Wait()
194 }()
195
196 fmt.Printf("Opening: %s (using built-in browser)\n", url)
197 return nil
198}
199
200// commandExists checks if a command is available in the system PATH
201func commandExists(cmd string) bool {
202 _, err := exec.LookPath(cmd)
203 return err == nil
204}