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