1package parser
2
3import (
4 "fmt"
5 neturl "net/url"
6 "regexp"
7 "strings"
8 "time"
9
10 "github.com/bnema/dumber/internal/config"
11)
12
13// ParseInput parses user input and returns a ParseResult with URL and metadata.
14// Now uses the proven CLI parsing logic for consistent behavior.
15func (p *Parser) ParseInput(input string) (*ParseResult, error) {
16 startTime := time.Now()
17
18 if input == "" {
19 return &ParseResult{
20 Type: InputTypeFallbackSearch,
21 URL: fmt.Sprintf(p.config.DefaultSearchEngine, ""),
22 Query: input,
23 Confidence: 0.0,
24 ProcessingTime: time.Since(startTime),
25 }, nil
26 }
27
28 // Use the working CLI parsing logic
29 finalURL, err := p.parseInputUsingCLILogic(input)
30 if err != nil {
31 // If parsing fails, fall back to search
32 finalURL = fmt.Sprintf(p.config.DefaultSearchEngine, neturl.QueryEscape(input))
33 }
34
35 // Determine result type based on the URL
36 var resultType InputType
37 var confidence float64
38
39 // Check if this is a search query result
40 isSearchResult := strings.Contains(finalURL, neturl.QueryEscape(input))
41
42 switch {
43 case isSearchResult:
44 resultType = InputTypeFallbackSearch
45 confidence = 0.1
46 case strings.HasPrefix(finalURL, "http://"),
47 strings.HasPrefix(finalURL, "https://"),
48 strings.HasPrefix(finalURL, "dumb://"),
49 strings.HasPrefix(finalURL, "file://"):
50 resultType = InputTypeDirectURL
51 confidence = 1.0
52 default:
53 resultType = InputTypeFallbackSearch
54 confidence = 0.1
55 }
56
57 // Check if it was a shortcut
58 if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") {
59 shortcut := strings.TrimSpace(input[:idx])
60 if _, exists := p.config.SearchShortcuts[shortcut]; exists {
61 resultType = InputTypeSearchShortcut
62 confidence = 0.95
63 }
64 }
65
66 return &ParseResult{
67 Type: resultType,
68 URL: finalURL,
69 Query: input,
70 Confidence: confidence,
71 ProcessingTime: time.Since(startTime),
72 }, nil
73}
74
75// parseDirectURL handles direct URL inputs using CLI logic.
76func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
77 // Use the working CLI parsing logic instead
78 finalURL, err := p.parseInputUsingCLILogic(cleanInput)
79 if err != nil {
80 return nil, err
81 }
82
83 return &ParseResult{
84 Type: InputTypeDirectURL,
85 URL: finalURL,
86 Query: originalInput,
87 Confidence: 1.0,
88 ProcessingTime: time.Since(startTime),
89 }, nil
90}
91
92// parseInputUsingCLILogic implements the working CLI parsing logic
93func (p *Parser) parseInputUsingCLILogic(input string) (string, error) {
94 input = strings.TrimSpace(input)
95
96 // 1. Check if it's a shortcut (format: "prefix:query")
97 if idx := strings.Index(input, ":"); idx > 0 && idx < 10 && !strings.Contains(input, "://") {
98 shortcut := strings.TrimSpace(input[:idx])
99 query := strings.TrimSpace(input[idx+1:])
100
101 if query != "" {
102 // First check configuration-based shortcuts
103 if shortcutCfg, exists := p.config.SearchShortcuts[shortcut]; exists {
104 return fmt.Sprintf(shortcutCfg.URL, neturl.QueryEscape(query)), nil
105 }
106
107 return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
108 }
109 }
110
111 // 2. Already a full URL with protocol
112 if regexp.MustCompile(`^https?://`).MatchString(input) {
113 return input, nil
114 }
115
116 // 3. File protocol
117 if strings.HasPrefix(input, "file://") {
118 return input, nil
119 }
120
121 // 4. Dumb protocol for custom browser pages
122 if strings.HasPrefix(input, "dumb://") {
123 return input, nil
124 }
125
126 // 5. Localhost/development URLs
127 if regexp.MustCompile(`^(localhost|127\.0\.0\.1|0\.0\.0\.0|::1)(:\d+)?(/.*)?$`).MatchString(input) {
128 return "http://" + input, nil
129 }
130
131 // 6. Local network IPs
132 if regexp.MustCompile(`^(192\.168|10\.|172\.(1[6-9]|2[0-9]|3[01]))\.\d+\.\d+(:\d+)?(/.*)?$`).MatchString(input) {
133 return "http://" + input, nil
134 }
135
136 // 7. Domain with TLD (must have dot and valid TLD)
137 // More comprehensive TLD pattern for better URL detection
138 domainPattern := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(/.*)?$`)
139 if domainPattern.MatchString(input) {
140 // Additional check for common TLDs to reduce false positives
141 commonTLDs := regexp.MustCompile(`\.(com|org|net|edu|gov|io|co|uk|de|fr|jp|cn|in|br|au|ca|ru|nl|it|es|se|no|fi|dk|pl|ch|at|be|cz|gr|il|mx|nz|sg|kr|tw|hk|my|th|vn|id|ph|za|eg|ng|ke|dev|app|xyz|tech|site|online|store|blog|info|biz|name|pro)(/.*)?$`)
142 if commonTLDs.MatchString(input) {
143 return "https://" + input, nil
144 }
145
146 // If it has a dot but uncommon TLD, still try as URL
147 return "https://" + input, nil
148 }
149
150 // 8. Everything else is a search query
151 // This includes: single words, multi-word phrases, questions, special characters, etc.
152 // Single words without TLDs should be treated as search queries, not hostnames
153 return fmt.Sprintf(p.config.DefaultSearchEngine, neturl.QueryEscape(input)), nil
154}
155
156// parseSearchShortcut handles search shortcut inputs.
157func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
158 validator := NewURLValidator()
159 isShortcut, shortcutKey, query := validator.IsSearchShortcut(cleanInput)
160
161 if !isShortcut {
162 return nil, fmt.Errorf("input is not a valid search shortcut")
163 }
164
165 // Look up the shortcut in configuration
166 shortcuts := p.config.SearchShortcuts
167 shortcutConfig, exists := shortcuts[shortcutKey]
168 if !exists {
169 // Unknown shortcut, treat as search query
170 return p.parseHistorySearch(cleanInput, originalInput, startTime)
171 }
172
173 // Build URL from shortcut template
174 finalURL, err := p.processShortcut(shortcutKey, query, shortcutConfig)
175 if err != nil {
176 return nil, fmt.Errorf("failed to process shortcut: %w", err)
177 }
178
179 return &ParseResult{
180 Type: InputTypeSearchShortcut,
181 URL: finalURL,
182 Query: originalInput,
183 Confidence: 0.95, // High confidence for known shortcuts
184 Shortcut: &DetectedShortcut{
185 Key: shortcutKey,
186 Query: query,
187 URL: finalURL,
188 Description: shortcutConfig.Description,
189 },
190 ProcessingTime: time.Since(startTime),
191 }, nil
192}
193
194// parseHistorySearch handles history search with fuzzy finding.
195func (p *Parser) parseHistorySearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
196 // Get history entries for fuzzy search
197 history, err := p.historyProvider.GetAllHistory()
198 if err != nil {
199 // If history search fails, fall back to web search
200 return p.parseFallbackSearch(cleanInput, originalInput, startTime)
201 }
202
203 // Perform fuzzy search
204 fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
205 matches := fuzzyMatcher.SearchHistory(cleanInput, history)
206
207 // Check if we have good matches
208 if len(matches) > 0 && matches[0].Score >= p.fuzzyConfig.MinSimilarityThreshold {
209 // Use the best match
210 bestMatch := matches[0]
211
212 return &ParseResult{
213 Type: InputTypeHistorySearch,
214 URL: bestMatch.HistoryEntry.Url,
215 Query: originalInput,
216 Confidence: bestMatch.Score,
217 FuzzyMatches: matches,
218 ProcessingTime: time.Since(startTime),
219 }, nil
220 }
221
222 // No good history matches, fall back to search
223 fallbackResult, err := p.parseFallbackSearch(cleanInput, originalInput, startTime)
224 if err != nil {
225 return nil, err
226 }
227
228 // Include the fuzzy matches even though we're falling back
229 fallbackResult.FuzzyMatches = matches
230
231 return fallbackResult, nil
232}
233
234// parseFallbackSearch handles fallback web search.
235func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
236 // Use default search shortcut (usually "g" for Google)
237 searchURL := p.buildSearchURL("", cleanInput)
238
239 return &ParseResult{
240 Type: InputTypeFallbackSearch,
241 URL: searchURL,
242 Query: originalInput,
243 Confidence: 0.1, // Low confidence for fallback
244 ProcessingTime: time.Since(startTime),
245 }, nil
246}
247
248// processShortcut processes a search shortcut and builds the final URL.
249func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) {
250 urlTemplate := shortcut.URL
251
252 // Replace placeholder with query
253 q := neturl.QueryEscape(query)
254 finalURL := strings.ReplaceAll(urlTemplate, "{query}", q)
255 finalURL = strings.ReplaceAll(finalURL, "%s", q)
256
257 // Validate the resulting URL
258 validator := NewURLValidator()
259 if !validator.IsValidURL(finalURL) && !strings.HasPrefix(finalURL, "http") {
260 return "", fmt.Errorf("invalid URL generated from shortcut '%s' template: %s", shortcutKey, finalURL)
261 }
262
263 return finalURL, nil
264}
265
266// buildSearchURL builds a search URL using the default search engine.
267func (p *Parser) buildSearchURL(searchEngine, query string) string {
268 q := neturl.QueryEscape(query)
269 // If no specific search engine, use the configured default
270 if searchEngine == "" {
271 return fmt.Sprintf(p.config.DefaultSearchEngine, q)
272 }
273
274 // Use specified search engine
275 shortcut, exists := p.config.SearchShortcuts[searchEngine]
276 if !exists {
277 // Fallback to default search engine if shortcut not found
278 return fmt.Sprintf(p.config.DefaultSearchEngine, q)
279 }
280
281 u := strings.ReplaceAll(shortcut.URL, "{query}", q)
282 u = strings.ReplaceAll(u, "%s", q)
283 return u
284}
285
286// FuzzySearchHistory performs fuzzy search on history and returns ranked matches.
287func (p *Parser) FuzzySearchHistory(query string, threshold float64) ([]FuzzyMatch, error) {
288 if query == "" {
289 return nil, nil
290 }
291
292 // Get all history entries
293 history, err := p.historyProvider.GetAllHistory()
294 if err != nil {
295 return nil, fmt.Errorf("failed to retrieve history: %w", err)
296 }
297
298 // Create fuzzy config with custom threshold if provided
299 fuzzyConfig := *p.fuzzyConfig
300 if threshold > 0 && threshold <= 1.0 {
301 fuzzyConfig.MinSimilarityThreshold = threshold
302 }
303
304 // Perform fuzzy search
305 fuzzyMatcher := NewFuzzyMatcher(&fuzzyConfig)
306 matches := fuzzyMatcher.SearchHistory(query, history)
307
308 // Apply additional ranking
309 matches = fuzzyMatcher.RankMatches(matches, query)
310
311 return matches, nil
312}
313
314// CalculateSimilarity calculates similarity between two strings.
315func (p *Parser) CalculateSimilarity(s1, s2 string) float64 {
316 fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
317 return fuzzyMatcher.calculateSimilarity(s1, s2)
318}
319
320// RankMatches re-ranks fuzzy matches based on additional criteria.
321func (p *Parser) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch {
322 fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
323 return fuzzyMatcher.RankMatches(matches, query)
324}
325
326// IsValidURL checks if the input represents a valid URL.
327func (p *Parser) IsValidURL(input string) bool {
328 validator := NewURLValidator()
329 return validator.IsValidURL(input)
330}
331
332// ProcessShortcut processes a shortcut and returns the resulting URL.
333func (p *Parser) ProcessShortcut(shortcut, query string, shortcuts map[string]config.SearchShortcut) (string, error) {
334 shortcutConfig, exists := shortcuts[shortcut]
335 if !exists {
336 return "", fmt.Errorf("shortcut '%s' not found", shortcut)
337 }
338
339 return p.processShortcut(shortcut, query, shortcutConfig)
340}
341
342// GetSupportedShortcuts returns a list of configured search shortcuts.
343func (p *Parser) GetSupportedShortcuts() map[string]config.SearchShortcut {
344 return p.config.SearchShortcuts
345}
346
347// UpdateFuzzyConfig updates the fuzzy matching configuration.
348func (p *Parser) UpdateFuzzyConfig(newConfig *FuzzyConfig) error {
349 if !newConfig.IsValid() {
350 return fmt.Errorf("invalid fuzzy configuration")
351 }
352
353 p.fuzzyConfig = newConfig
354 return nil
355}
356
357// GetFuzzyConfig returns the current fuzzy matching configuration.
358func (p *Parser) GetFuzzyConfig() *FuzzyConfig {
359 return p.fuzzyConfig
360}
361
362// PreviewParse provides a preview of what ParseInput would return without side effects.
363func (p *Parser) PreviewParse(input string) (*ParseResult, error) {
364 // This is essentially the same as ParseInput but could be extended
365 // to avoid any side effects if needed in the future
366 return p.ParseInput(input)
367}
368
369// SuggestCompletions provides URL/search completions based on input.
370func (p *Parser) SuggestCompletions(input string, limit int) ([]string, error) {
371 if input == "" || limit <= 0 {
372 return nil, nil
373 }
374
375 // Get fuzzy matches from history
376 matches, err := p.FuzzySearchHistory(input, 0.2) // Lower threshold for suggestions
377 if err != nil {
378 return nil, err
379 }
380
381 completions := make([]string, 0, limit)
382 seen := make(map[string]bool)
383
384 // Add URLs from fuzzy matches
385 for _, match := range matches {
386 if len(completions) >= limit {
387 break
388 }
389
390 url := match.HistoryEntry.Url
391 if !seen[url] {
392 completions = append(completions, url)
393 seen[url] = true
394 }
395 }
396
397 // Add search shortcuts if input looks like it might be a shortcut
398 if strings.Contains(input, ":") || len(input) <= 3 {
399 for shortcut, config := range p.config.SearchShortcuts {
400 if len(completions) >= limit {
401 break
402 }
403
404 if strings.HasPrefix(shortcut, strings.ToLower(input)) {
405 suggestion := fmt.Sprintf("%s: %s", shortcut, config.Description)
406 if !seen[suggestion] {
407 completions = append(completions, suggestion)
408 seen[suggestion] = true
409 }
410 }
411 }
412 }
413
414 return completions, nil
415}
416
417// ValidateConfig validates the parser configuration.
418func (p *Parser) ValidateConfig() error {
419 if p.config == nil {
420 return fmt.Errorf("parser config is nil")
421 }
422
423 if p.fuzzyConfig == nil {
424 return fmt.Errorf("fuzzy config is nil")
425 }
426
427 if !p.fuzzyConfig.IsValid() {
428 return fmt.Errorf("invalid fuzzy config")
429 }
430
431 if p.historyProvider == nil {
432 return fmt.Errorf("history provider is nil")
433 }
434
435 // Validate search shortcuts
436 if len(p.config.SearchShortcuts) == 0 {
437 return fmt.Errorf("no search shortcuts configured")
438 }
439
440 // Check for default search engine
441 if p.config.DefaultSearchEngine == "" {
442 return fmt.Errorf("default search engine is not configured")
443 }
444
445 if !strings.Contains(p.config.DefaultSearchEngine, "%s") {
446 return fmt.Errorf("default search engine must contain %%s placeholder")
447 }
448
449 return nil
450}