1package parser
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/bnema/dumber/internal/config"
9)
10
11// ParseInput parses user input and returns a ParseResult with URL and metadata.
12//
13// Learning Behavior: When users search with shortcuts like "g: viper cli" and visit
14// results, those URLs are recorded in history. Future searches for "viper cli" will
15// find the GitHub page in history via fuzzy matching, creating intelligent learning.
16func (p *Parser) ParseInput(input string) (*ParseResult, error) {
17 startTime := time.Now()
18
19 // Sanitize input
20 validator := NewURLValidator()
21 cleanInput := validator.SanitizeInput(input)
22
23 if cleanInput == "" {
24 return &ParseResult{
25 Type: InputTypeFallbackSearch,
26 URL: p.buildSearchURL("", cleanInput),
27 Query: input,
28 Confidence: 0.0,
29 ProcessingTime: time.Since(startTime),
30 }, nil
31 }
32
33 // Determine input type
34 inputType := validator.GetURLType(cleanInput)
35
36 switch inputType {
37 case InputTypeDirectURL:
38 return p.parseDirectURL(cleanInput, input, startTime)
39
40 case InputTypeSearchShortcut:
41 return p.parseSearchShortcut(cleanInput, input, startTime)
42
43 case InputTypeHistorySearch:
44 return p.parseHistorySearch(cleanInput, input, startTime)
45
46 default:
47 return p.parseFallbackSearch(cleanInput, input, startTime)
48 }
49}
50
51// parseDirectURL handles direct URL inputs.
52func (p *Parser) parseDirectURL(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
53 validator := NewURLValidator()
54 normalizedURL := validator.NormalizeURL(cleanInput)
55
56 return &ParseResult{
57 Type: InputTypeDirectURL,
58 URL: normalizedURL,
59 Query: originalInput,
60 Confidence: 1.0, // High confidence for direct URLs
61 ProcessingTime: time.Since(startTime),
62 }, nil
63}
64
65// parseSearchShortcut handles search shortcut inputs.
66func (p *Parser) parseSearchShortcut(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
67 validator := NewURLValidator()
68 isShortcut, shortcutKey, query := validator.IsSearchShortcut(cleanInput)
69
70 if !isShortcut {
71 return nil, fmt.Errorf("input is not a valid search shortcut")
72 }
73
74 // Look up the shortcut in configuration
75 shortcuts := p.config.SearchShortcuts
76 shortcutConfig, exists := shortcuts[shortcutKey]
77 if !exists {
78 // Unknown shortcut, treat as search query
79 return p.parseHistorySearch(cleanInput, originalInput, startTime)
80 }
81
82 // Build URL from shortcut template
83 finalURL, err := p.processShortcut(shortcutKey, query, shortcutConfig)
84 if err != nil {
85 return nil, fmt.Errorf("failed to process shortcut: %w", err)
86 }
87
88 return &ParseResult{
89 Type: InputTypeSearchShortcut,
90 URL: finalURL,
91 Query: originalInput,
92 Confidence: 0.95, // High confidence for known shortcuts
93 Shortcut: &DetectedShortcut{
94 Key: shortcutKey,
95 Query: query,
96 URL: finalURL,
97 Description: shortcutConfig.Description,
98 },
99 ProcessingTime: time.Since(startTime),
100 }, nil
101}
102
103// parseHistorySearch handles history search with fuzzy finding.
104func (p *Parser) parseHistorySearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
105 // Get history entries for fuzzy search
106 history, err := p.historyProvider.GetAllHistory()
107 if err != nil {
108 // If history search fails, fall back to web search
109 return p.parseFallbackSearch(cleanInput, originalInput, startTime)
110 }
111
112 // Perform fuzzy search
113 fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
114 matches := fuzzyMatcher.SearchHistory(cleanInput, history)
115
116 // Check if we have good matches
117 if len(matches) > 0 && matches[0].Score >= p.fuzzyConfig.MinSimilarityThreshold {
118 // Use the best match
119 bestMatch := matches[0]
120
121 return &ParseResult{
122 Type: InputTypeHistorySearch,
123 URL: bestMatch.HistoryEntry.Url,
124 Query: originalInput,
125 Confidence: bestMatch.Score,
126 FuzzyMatches: matches,
127 ProcessingTime: time.Since(startTime),
128 }, nil
129 }
130
131 // No good history matches, fall back to search
132 fallbackResult, err := p.parseFallbackSearch(cleanInput, originalInput, startTime)
133 if err != nil {
134 return nil, err
135 }
136
137 // Include the fuzzy matches even though we're falling back
138 fallbackResult.FuzzyMatches = matches
139
140 return fallbackResult, nil
141}
142
143// parseFallbackSearch handles fallback web search.
144func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime time.Time) (*ParseResult, error) {
145 // Use default search shortcut (usually "g" for Google)
146 searchURL := p.buildSearchURL("", cleanInput)
147
148 return &ParseResult{
149 Type: InputTypeFallbackSearch,
150 URL: searchURL,
151 Query: originalInput,
152 Confidence: 0.1, // Low confidence for fallback
153 ProcessingTime: time.Since(startTime),
154 }, nil
155}
156
157// processShortcut processes a search shortcut and builds the final URL.
158func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) {
159 urlTemplate := shortcut.URL
160
161 // Replace placeholder with query
162 finalURL := strings.ReplaceAll(urlTemplate, "{query}", query)
163
164 // Validate the resulting URL
165 validator := NewURLValidator()
166 if !validator.IsValidURL(finalURL) && !strings.HasPrefix(finalURL, "http") {
167 return "", fmt.Errorf("invalid URL generated from shortcut template: %s", finalURL)
168 }
169
170 return finalURL, nil
171}
172
173// buildSearchURL builds a search URL using the default search engine.
174func (p *Parser) buildSearchURL(searchEngine, query string) string {
175 // If no specific search engine, use Google as default
176 if searchEngine == "" {
177 defaultShortcut, exists := p.config.SearchShortcuts["g"]
178 if exists {
179 url := strings.ReplaceAll(defaultShortcut.URL, "{query}", query)
180 return url
181 }
182 // Fallback to Google if no "g" shortcut configured
183 return fmt.Sprintf("https://www.google.com/search?q=%s", query)
184 }
185
186 // Use specified search engine
187 shortcut, exists := p.config.SearchShortcuts[searchEngine]
188 if !exists {
189 return fmt.Sprintf("https://www.google.com/search?q=%s", query)
190 }
191
192 return strings.ReplaceAll(shortcut.URL, "{query}", query)
193}
194
195// FuzzySearchHistory performs fuzzy search on history and returns ranked matches.
196func (p *Parser) FuzzySearchHistory(query string, threshold float64) ([]FuzzyMatch, error) {
197 if query == "" {
198 return nil, nil
199 }
200
201 // Get all history entries
202 history, err := p.historyProvider.GetAllHistory()
203 if err != nil {
204 return nil, fmt.Errorf("failed to retrieve history: %w", err)
205 }
206
207 // Create fuzzy config with custom threshold if provided
208 fuzzyConfig := *p.fuzzyConfig
209 if threshold > 0 && threshold <= 1.0 {
210 fuzzyConfig.MinSimilarityThreshold = threshold
211 }
212
213 // Perform fuzzy search
214 fuzzyMatcher := NewFuzzyMatcher(&fuzzyConfig)
215 matches := fuzzyMatcher.SearchHistory(query, history)
216
217 // Apply additional ranking
218 matches = fuzzyMatcher.RankMatches(matches, query)
219
220 return matches, nil
221}
222
223// CalculateSimilarity calculates similarity between two strings.
224func (p *Parser) CalculateSimilarity(s1, s2 string) float64 {
225 fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
226 return fuzzyMatcher.calculateSimilarity(s1, s2)
227}
228
229// RankMatches re-ranks fuzzy matches based on additional criteria.
230func (p *Parser) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch {
231 fuzzyMatcher := NewFuzzyMatcher(p.fuzzyConfig)
232 return fuzzyMatcher.RankMatches(matches, query)
233}
234
235// IsValidURL checks if the input represents a valid URL.
236func (p *Parser) IsValidURL(input string) bool {
237 validator := NewURLValidator()
238 return validator.IsValidURL(input)
239}
240
241// ProcessShortcut processes a shortcut and returns the resulting URL.
242func (p *Parser) ProcessShortcut(shortcut, query string, shortcuts map[string]config.SearchShortcut) (string, error) {
243 shortcutConfig, exists := shortcuts[shortcut]
244 if !exists {
245 return "", fmt.Errorf("shortcut '%s' not found", shortcut)
246 }
247
248 return p.processShortcut(shortcut, query, shortcutConfig)
249}
250
251// GetSupportedShortcuts returns a list of configured search shortcuts.
252func (p *Parser) GetSupportedShortcuts() map[string]config.SearchShortcut {
253 return p.config.SearchShortcuts
254}
255
256// UpdateFuzzyConfig updates the fuzzy matching configuration.
257func (p *Parser) UpdateFuzzyConfig(newConfig *FuzzyConfig) error {
258 if !newConfig.IsValid() {
259 return fmt.Errorf("invalid fuzzy configuration")
260 }
261
262 p.fuzzyConfig = newConfig
263 return nil
264}
265
266// GetFuzzyConfig returns the current fuzzy matching configuration.
267func (p *Parser) GetFuzzyConfig() *FuzzyConfig {
268 return p.fuzzyConfig
269}
270
271// PreviewParse provides a preview of what ParseInput would return without side effects.
272func (p *Parser) PreviewParse(input string) (*ParseResult, error) {
273 // This is essentially the same as ParseInput but could be extended
274 // to avoid any side effects if needed in the future
275 return p.ParseInput(input)
276}
277
278// SuggestCompletions provides URL/search completions based on input.
279func (p *Parser) SuggestCompletions(input string, limit int) ([]string, error) {
280 if input == "" || limit <= 0 {
281 return nil, nil
282 }
283
284 // Get fuzzy matches from history
285 matches, err := p.FuzzySearchHistory(input, 0.2) // Lower threshold for suggestions
286 if err != nil {
287 return nil, err
288 }
289
290 completions := make([]string, 0, limit)
291 seen := make(map[string]bool)
292
293 // Add URLs from fuzzy matches
294 for _, match := range matches {
295 if len(completions) >= limit {
296 break
297 }
298
299 url := match.HistoryEntry.Url
300 if !seen[url] {
301 completions = append(completions, url)
302 seen[url] = true
303 }
304 }
305
306 // Add search shortcuts if input looks like it might be a shortcut
307 if strings.Contains(input, ":") || len(input) <= 3 {
308 for shortcut, config := range p.config.SearchShortcuts {
309 if len(completions) >= limit {
310 break
311 }
312
313 if strings.HasPrefix(shortcut, strings.ToLower(input)) {
314 suggestion := fmt.Sprintf("%s: %s", shortcut, config.Description)
315 if !seen[suggestion] {
316 completions = append(completions, suggestion)
317 seen[suggestion] = true
318 }
319 }
320 }
321 }
322
323 return completions, nil
324}
325
326// ValidateConfig validates the parser configuration.
327func (p *Parser) ValidateConfig() error {
328 if p.config == nil {
329 return fmt.Errorf("parser config is nil")
330 }
331
332 if p.fuzzyConfig == nil {
333 return fmt.Errorf("fuzzy config is nil")
334 }
335
336 if !p.fuzzyConfig.IsValid() {
337 return fmt.Errorf("invalid fuzzy config")
338 }
339
340 if p.historyProvider == nil {
341 return fmt.Errorf("history provider is nil")
342 }
343
344 // Validate search shortcuts
345 if len(p.config.SearchShortcuts) == 0 {
346 return fmt.Errorf("no search shortcuts configured")
347 }
348
349 // Check for default search shortcut
350 if _, hasGoogle := p.config.SearchShortcuts["g"]; !hasGoogle {
351 // Warning: no default search configured
352 }
353
354 return nil
355}