v0.15.0 9006957

parser_test.go RawHistoryFolder

1package parser
2
3import (
4	"database/sql"
5	"strings"
6	"testing"
7	"time"
8
9	"github.com/bnema/dumber/internal/config"
10	"github.com/bnema/dumber/internal/db"
11)
12
13// MockHistoryProvider implements HistoryProvider for testing.
14type MockHistoryProvider struct {
15	history []*db.History
16}
17
18func (m *MockHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) {
19	if limit >= len(m.history) {
20		return m.history, nil
21	}
22	return m.history[:limit], nil
23}
24
25func (m *MockHistoryProvider) GetAllHistory() ([]*db.History, error) {
26	return m.history, nil
27}
28
29func (m *MockHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) {
30	// Simple mock search - return entries that contain the query in URL or title
31	result := make([]*db.History, 0)
32	queryLower := strings.ToLower(query)
33	for _, entry := range m.history {
34		if len(result) >= limit {
35			break
36		}
37		// Check if query matches URL or title
38		if strings.Contains(strings.ToLower(entry.Url), queryLower) ||
39			(entry.Title.Valid && strings.Contains(strings.ToLower(entry.Title.String), queryLower)) {
40			result = append(result, entry)
41		}
42	}
43	return result, nil
44}
45
46func (m *MockHistoryProvider) GetHistoryByURL(url string) (*db.History, error) {
47	for _, entry := range m.history {
48		if entry.Url == url {
49			return entry, nil
50		}
51	}
52	return nil, sql.ErrNoRows
53}
54
55// createMockHistory creates mock history data for testing.
56func createMockHistory() []*db.History {
57	now := time.Now()
58	return []*db.History{
59		{
60			ID:          1,
61			Url:         "https://github.com",
62			Title:       sql.NullString{String: "GitHub", Valid: true},
63			VisitCount:  sql.NullInt64{Int64: 25, Valid: true},
64			LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
65			CreatedAt:   sql.NullTime{Time: now.Add(-30 * 24 * time.Hour), Valid: true},
66		},
67		{
68			ID:          2,
69			Url:         "https://stackoverflow.com",
70			Title:       sql.NullString{String: "Stack Overflow", Valid: true},
71			VisitCount:  sql.NullInt64{Int64: 15, Valid: true},
72			LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
73			CreatedAt:   sql.NullTime{Time: now.Add(-20 * 24 * time.Hour), Valid: true},
74		},
75		{
76			ID:          3,
77			Url:         "https://reddit.com",
78			Title:       sql.NullString{String: "Reddit", Valid: true},
79			VisitCount:  sql.NullInt64{Int64: 50, Valid: true},
80			LastVisited: sql.NullTime{Time: now.Add(-3 * time.Hour), Valid: true},
81			CreatedAt:   sql.NullTime{Time: now.Add(-10 * 24 * time.Hour), Valid: true},
82		},
83		{
84			ID:          4,
85			Url:         "https://golang.org",
86			Title:       sql.NullString{String: "The Go Programming Language", Valid: true},
87			VisitCount:  sql.NullInt64{Int64: 8, Valid: true},
88			LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
89			CreatedAt:   sql.NullTime{Time: now.Add(-5 * 24 * time.Hour), Valid: true},
90		},
91	}
92}
93
94// createTestConfig creates a test configuration.
95func createTestConfig() *config.Config {
96	return &config.Config{
97		DefaultSearchEngine: "https://www.google.com/search?q=%s",
98		SearchShortcuts: map[string]config.SearchShortcut{
99			"g": {
100				URL:         "https://www.google.com/search?q={query}",
101				Description: "Google Search",
102			},
103			"gh": {
104				URL:         "https://github.com/search?q={query}",
105				Description: "GitHub Search",
106			},
107			"so": {
108				URL:         "https://stackoverflow.com/search?q={query}",
109				Description: "Stack Overflow Search",
110			},
111		},
112	}
113}
114
115func TestParser_ParseInput_DirectURL(t *testing.T) {
116	mockHistory := createMockHistory()
117	provider := &MockHistoryProvider{history: mockHistory}
118	config := createTestConfig()
119	parser := NewParser(config, provider)
120
121	tests := []struct {
122		name       string
123		input      string
124		expectType InputType
125		expectURL  string
126		expectConf float64
127	}{
128		{
129			name:       "HTTPS URL",
130			input:      "https://example.com",
131			expectType: InputTypeDirectURL,
132			expectURL:  "https://example.com",
133			expectConf: 1.0,
134		},
135		{
136			name:       "HTTP URL",
137			input:      "http://example.com",
138			expectType: InputTypeDirectURL,
139			expectURL:  "http://example.com",
140			expectConf: 1.0,
141		},
142		{
143			name:       "Domain only",
144			input:      "example.com",
145			expectType: InputTypeDirectURL,
146			expectURL:  "https://example.com",
147			expectConf: 1.0,
148		},
149		{
150			name:       "Subdomain",
151			input:      "www.example.com",
152			expectType: InputTypeDirectURL,
153			expectURL:  "https://www.example.com",
154			expectConf: 1.0,
155		},
156		{
157			name:       "Domain with path",
158			input:      "example.com/path",
159			expectType: InputTypeDirectURL,
160			expectURL:  "https://example.com/path",
161			expectConf: 1.0,
162		},
163	}
164
165	for _, tt := range tests {
166		t.Run(tt.name, func(t *testing.T) {
167			result, err := parser.ParseInput(tt.input)
168			if err != nil {
169				t.Fatalf("ParseInput() error = %v", err)
170			}
171
172			if result.Type != tt.expectType {
173				t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
174			}
175
176			if result.URL != tt.expectURL {
177				t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL)
178			}
179
180			if result.Confidence != tt.expectConf {
181				t.Errorf("ParseInput() confidence = %v, want %v", result.Confidence, tt.expectConf)
182			}
183		})
184	}
185}
186
187func TestParser_ParseInput_SearchShortcut(t *testing.T) {
188	mockHistory := createMockHistory()
189	provider := &MockHistoryProvider{history: mockHistory}
190	config := createTestConfig()
191	parser := NewParser(config, provider)
192
193	tests := []struct {
194		name        string
195		input       string
196		expectType  InputType
197		expectURL   string
198		expectKey   string
199		expectQuery string
200	}{
201		{
202			name:        "Google search",
203			input:       "g: golang tutorial",
204			expectType:  InputTypeSearchShortcut,
205			expectURL:   "https://www.google.com/search?q=golang+tutorial",
206			expectKey:   "g",
207			expectQuery: "golang tutorial",
208		},
209		{
210			name:        "GitHub search",
211			input:       "gh: cobra cli",
212			expectType:  InputTypeSearchShortcut,
213			expectURL:   "https://github.com/search?q=cobra+cli",
214			expectKey:   "gh",
215			expectQuery: "cobra cli",
216		},
217		{
218			name:        "Stack Overflow search",
219			input:       "so: how to fuzzy search",
220			expectType:  InputTypeSearchShortcut,
221			expectURL:   "https://stackoverflow.com/search?q=how+to+fuzzy+search",
222			expectKey:   "so",
223			expectQuery: "how to fuzzy search",
224		},
225	}
226
227	for _, tt := range tests {
228		t.Run(tt.name, func(t *testing.T) {
229			result, err := parser.ParseInput(tt.input)
230			if err != nil {
231				t.Fatalf("ParseInput() error = %v", err)
232			}
233
234			if result.Type != tt.expectType {
235				t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
236			}
237
238			if result.URL != tt.expectURL {
239				t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL)
240			}
241
242			if result.Shortcut == nil {
243				t.Fatalf("ParseInput() shortcut is nil")
244			}
245
246			if result.Shortcut.Key != tt.expectKey {
247				t.Errorf("ParseInput() shortcut key = %v, want %v", result.Shortcut.Key, tt.expectKey)
248			}
249
250			if result.Shortcut.Query != tt.expectQuery {
251				t.Errorf("ParseInput() shortcut query = %v, want %v", result.Shortcut.Query, tt.expectQuery)
252			}
253		})
254	}
255}
256
257func TestParser_ParseInput_SearchTerms(t *testing.T) {
258	// Note: ParseInput now uses CLI-style parsing which converts single words
259	// (without TLDs) into search URLs. The result type is DirectURL because
260	// the generated URL has a scheme. The key behavior is that single words
261	// become search engine queries.
262	mockHistory := createMockHistory()
263	provider := &MockHistoryProvider{history: mockHistory}
264	config := createTestConfig()
265	parser := NewParser(config, provider)
266
267	tests := []struct {
268		name      string
269		input     string
270		expectURL string // Should contain this in the URL
271	}{
272		{
273			name:      "Single word becomes search",
274			input:     "github",
275			expectURL: "google.com/search?q=github",
276		},
277		{
278			name:      "Another single word becomes search",
279			input:     "stack",
280			expectURL: "google.com/search?q=stack",
281		},
282		{
283			name:      "Search term becomes search",
284			input:     "golang",
285			expectURL: "google.com/search?q=golang",
286		},
287	}
288
289	for _, tt := range tests {
290		t.Run(tt.name, func(t *testing.T) {
291			result, err := parser.ParseInput(tt.input)
292			if err != nil {
293				t.Fatalf("ParseInput() error = %v", err)
294			}
295
296			// Key behavior: single words without TLDs become search URLs
297			if !strings.Contains(result.URL, tt.expectURL) {
298				t.Errorf("ParseInput() URL = %v, expected to contain %v", result.URL, tt.expectURL)
299			}
300		})
301	}
302}
303
304func TestParser_FuzzySearchHistory(t *testing.T) {
305	mockHistory := createMockHistory()
306	provider := &MockHistoryProvider{history: mockHistory}
307	config := createTestConfig()
308	parser := NewParser(config, provider)
309
310	tests := []struct {
311		name          string
312		query         string
313		threshold     float64
314		expectMatches int
315		expectFirst   string // Expected first match URL
316	}{
317		{
318			name:          "GitHub search",
319			query:         "github",
320			threshold:     0.3,
321			expectMatches: 1,
322			expectFirst:   "https://github.com",
323		},
324		{
325			name:          "Programming search",
326			query:         "programming",
327			threshold:     0.2,
328			expectMatches: 1,
329			expectFirst:   "https://golang.org",
330		},
331		{
332			name:          "Stack search",
333			query:         "stack",
334			threshold:     0.3,
335			expectMatches: 1,
336			expectFirst:   "https://stackoverflow.com",
337		},
338	}
339
340	for _, tt := range tests {
341		t.Run(tt.name, func(t *testing.T) {
342			matches, err := parser.FuzzySearchHistory(tt.query, tt.threshold)
343			if err != nil {
344				t.Fatalf("FuzzySearchHistory() error = %v", err)
345			}
346
347			if len(matches) < tt.expectMatches {
348				t.Errorf("FuzzySearchHistory() matches = %d, want >= %d", len(matches), tt.expectMatches)
349			}
350
351			if len(matches) > 0 && tt.expectFirst != "" {
352				if matches[0].HistoryEntry.Url != tt.expectFirst {
353					t.Errorf("FuzzySearchHistory() first match = %v, want %v", matches[0].HistoryEntry.Url, tt.expectFirst)
354				}
355			}
356		})
357	}
358}
359
360func TestParser_SuggestCompletions(t *testing.T) {
361	mockHistory := createMockHistory()
362	provider := &MockHistoryProvider{history: mockHistory}
363	config := createTestConfig()
364	parser := NewParser(config, provider)
365
366	tests := []struct {
367		name         string
368		input        string
369		limit        int
370		expectMin    int // Minimum expected completions
371		expectMax    int // Maximum expected completions
372		expectInList string
373	}{
374		{
375			name:         "GitHub completion",
376			input:        "gith",
377			limit:        5,
378			expectMin:    1,
379			expectMax:    5,
380			expectInList: "https://github.com", // Should include GitHub URL
381		},
382		{
383			name:      "Shortcut completion",
384			input:     "g",
385			limit:     5,
386			expectMin: 1, // At least shortcuts should match
387			expectMax: 5,
388		},
389		{
390			name:      "Empty input",
391			input:     "",
392			limit:     5,
393			expectMin: 0,
394			expectMax: 0,
395		},
396	}
397
398	for _, tt := range tests {
399		t.Run(tt.name, func(t *testing.T) {
400			completions, err := parser.SuggestCompletions(tt.input, tt.limit)
401			if err != nil {
402				t.Fatalf("SuggestCompletions() error = %v", err)
403			}
404
405			if len(completions) < tt.expectMin || len(completions) > tt.expectMax {
406				t.Errorf("SuggestCompletions() count = %d, want between %d and %d", len(completions), tt.expectMin, tt.expectMax)
407			}
408
409			// Check if expected item is in the list
410			if tt.expectInList != "" {
411				found := false
412				for _, c := range completions {
413					if strings.Contains(c, tt.expectInList) {
414						found = true
415						break
416					}
417				}
418				if !found {
419					t.Errorf("SuggestCompletions() expected %q in completions, got %v", tt.expectInList, completions)
420				}
421			}
422		})
423	}
424}
425
426func TestParser_ProcessShortcut(t *testing.T) {
427	config := createTestConfig()
428	mockHistory := createMockHistory()
429	provider := &MockHistoryProvider{history: mockHistory}
430	parser := NewParser(config, provider)
431
432	tests := []struct {
433		name      string
434		shortcut  string
435		query     string
436		expectURL string
437		expectErr bool
438	}{
439		{
440			name:      "Google shortcut",
441			shortcut:  "g",
442			query:     "test query",
443			expectURL: "https://www.google.com/search?q=test+query",
444			expectErr: false,
445		},
446		{
447			name:      "GitHub shortcut",
448			shortcut:  "gh",
449			query:     "golang",
450			expectURL: "https://github.com/search?q=golang",
451			expectErr: false,
452		},
453		{
454			name:      "Unknown shortcut",
455			shortcut:  "unknown",
456			query:     "test",
457			expectURL: "",
458			expectErr: true,
459		},
460	}
461
462	for _, tt := range tests {
463		t.Run(tt.name, func(t *testing.T) {
464			url, err := parser.ProcessShortcut(tt.shortcut, tt.query, config.SearchShortcuts)
465
466			if tt.expectErr {
467				if err == nil {
468					t.Errorf("ProcessShortcut() expected error but got none")
469				}
470				return
471			}
472
473			if err != nil {
474				t.Fatalf("ProcessShortcut() error = %v", err)
475			}
476
477			if url != tt.expectURL {
478				t.Errorf("ProcessShortcut() URL = %v, want %v", url, tt.expectURL)
479			}
480		})
481	}
482}
483
484func TestParser_ValidateConfig(t *testing.T) {
485	mockHistory := createMockHistory()
486	provider := &MockHistoryProvider{history: mockHistory}
487
488	tests := []struct {
489		name      string
490		config    *config.Config
491		fuzzy     *FuzzyConfig
492		provider  HistoryProvider
493		expectErr bool
494	}{
495		{
496			name:      "Valid configuration",
497			config:    createTestConfig(),
498			fuzzy:     DefaultFuzzyConfig(),
499			provider:  provider,
500			expectErr: false,
501		},
502		{
503			name:      "Nil config",
504			config:    nil,
505			fuzzy:     DefaultFuzzyConfig(),
506			provider:  provider,
507			expectErr: true,
508		},
509		{
510			name:      "Nil fuzzy config",
511			config:    createTestConfig(),
512			fuzzy:     nil,
513			provider:  provider,
514			expectErr: true,
515		},
516		{
517			name:      "Nil history provider",
518			config:    createTestConfig(),
519			fuzzy:     DefaultFuzzyConfig(),
520			provider:  nil,
521			expectErr: true,
522		},
523	}
524
525	for _, tt := range tests {
526		t.Run(tt.name, func(t *testing.T) {
527			parser := NewParser(tt.config, tt.provider, WithFuzzyConfig(tt.fuzzy))
528
529			err := parser.ValidateConfig()
530
531			if tt.expectErr {
532				if err == nil {
533					t.Errorf("ValidateConfig() expected error but got none")
534				}
535			} else {
536				if err != nil {
537					t.Errorf("ValidateConfig() unexpected error = %v", err)
538				}
539			}
540		})
541	}
542}
543
544func TestFuzzyConfig_IsValid(t *testing.T) {
545	tests := []struct {
546		name   string
547		config *FuzzyConfig
548		expect bool
549	}{
550		{
551			name:   "Default config",
552			config: DefaultFuzzyConfig(),
553			expect: true,
554		},
555		{
556			name: "Invalid threshold (negative)",
557			config: &FuzzyConfig{
558				MinSimilarityThreshold: -0.1,
559				MaxResults:             10,
560				URLWeight:              0.4,
561				TitleWeight:            0.3,
562				RecencyWeight:          0.2,
563				VisitWeight:            0.1,
564				RecencyDecayDays:       30,
565			},
566			expect: false,
567		},
568		{
569			name: "Invalid threshold (>1.0)",
570			config: &FuzzyConfig{
571				MinSimilarityThreshold: 1.1,
572				MaxResults:             10,
573				URLWeight:              0.4,
574				TitleWeight:            0.3,
575				RecencyWeight:          0.2,
576				VisitWeight:            0.1,
577				RecencyDecayDays:       30,
578			},
579			expect: false,
580		},
581		{
582			name: "Invalid MaxResults",
583			config: &FuzzyConfig{
584				MinSimilarityThreshold: 0.3,
585				MaxResults:             0,
586				URLWeight:              0.4,
587				TitleWeight:            0.3,
588				RecencyWeight:          0.2,
589				VisitWeight:            0.1,
590				RecencyDecayDays:       30,
591			},
592			expect: false,
593		},
594	}
595
596	for _, tt := range tests {
597		t.Run(tt.name, func(t *testing.T) {
598			result := tt.config.IsValid()
599			if result != tt.expect {
600				t.Errorf("FuzzyConfig.IsValid() = %v, want %v", result, tt.expect)
601			}
602		})
603	}
604}
605
606func TestInputType_String(t *testing.T) {
607	tests := []struct {
608		inputType InputType
609		expected  string
610	}{
611		{InputTypeDirectURL, "direct_url"},
612		{InputTypeSearchShortcut, "search_shortcut"},
613		{InputTypeHistorySearch, "history_search"},
614		{InputTypeFallbackSearch, "fallback_search"},
615		{InputType(99), "unknown"}, // Unknown type
616	}
617
618	for _, tt := range tests {
619		t.Run(tt.expected, func(t *testing.T) {
620			result := tt.inputType.String()
621			if result != tt.expected {
622				t.Errorf("InputType.String() = %v, want %v", result, tt.expected)
623			}
624		})
625	}
626}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close