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