detached 265b7cd

feat(core): implement complete dumb browser CLI with fuzzy search and learning behavior

- Add comprehensive CLI structure with browse, history, init, and dmenu commands
- Implement smart URL parser with direct URL detection, search shortcuts, and fuzzy history matching
- Create config system with JSON format, env var support, Viper live reload, and XDG compliance
- Build fuzzy finding algorithm with Jaro-Winkler similarity and intelligent ranking
- Add learning behavior: search shortcuts populate history for future fuzzy matching
- Implement dmenu/rofi integration with stdin/stdout protocol
- Create type-safe database operations with SQLC-generated code
- Support XDG directories: config (~/.config/dumber), state (~/.local/state/dumber)
- Add comprehensive test coverage for fuzzy matching and URL validation algorithms

Core functionality complete and ready for Wails browser integration
commit
265b7cd3d2182a11d95c77b4de33e61850a37dbe
author
bamen · · 2025-09-09 08:09 UTC
parent
a9751c3
signature
unsigned
31 files · +5256 -110
M .gitignore +2 -2
@@ -7,8 +7,8 @@ dist/
7 *.dylib7 *.dylib
8 8
9 # Binaries for programs and plugins9 # Binaries for programs and plugins
10-dumb-browser10+dumber
11-dumb-browser.exe11+dumber.exe
12 12
13 # Test binary, built with `go test -c`13 # Test binary, built with `go test -c`
14 *.test14 *.test
M Makefile +3 -3
@@ -1,10 +1,10 @@
1-# Makefile for dumb-browser1+# Makefile for dumber
2 2
3 .PHONY: build test lint clean install-tools dev generate help3 .PHONY: build test lint clean install-tools dev generate help
4 4
5 # Variables5 # Variables
6-BINARY_NAME=dumb-browser6+BINARY_NAME=dumber
7-MAIN_PATH=./cmd/dumb-browser7+MAIN_PATH=./cmd/dumber
8 DIST_DIR=dist8 DIST_DIR=dist
9 9
10 # Version information from git10 # Version information from git
A cmd/dumb-browser/main.go +62 -0
@@ -0,0 +1,62 @@
1+// Package main provides the main entry point for dumb-browser CLI application.
2+package main
3+
4+import (
5+ "fmt"
6+ "os"
7+
8+ "github.com/bnema/dumber/internal/cli"
9+ "github.com/bnema/dumber/internal/config"
10+)
11+
12+// Build information set via ldflags
13+var (
14+ version = "dev"
15+ commit = "none"
16+ buildDate = "unknown"
17+)
18+
19+func main() {
20+ // Initialize configuration system
21+ if err := config.Init(); err != nil {
22+ fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
23+ os.Exit(1)
24+ }
25+
26+ // Start configuration watching for live reload
27+ if err := config.Watch(); err != nil {
28+ // Don't exit on watch error, just warn
29+ fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
30+ }
31+
32+ rootCmd := cli.NewRootCmd(version, commit, buildDate)
33+
34+ // Handle dmenu flag at the root level for direct integration
35+ if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
36+ // Create a temporary CLI instance to handle dmenu mode
37+ cliInstance, err := cli.NewCLI()
38+ if err != nil {
39+ fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
40+ os.Exit(1)
41+ }
42+ defer func() {
43+ if closeErr := cliInstance.Close(); closeErr != nil {
44+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
45+ }
46+ }()
47+
48+ // Generate dmenu options
49+ dmenuCmd := cli.NewDmenuCmd()
50+ if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
51+ fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
52+ os.Exit(1)
53+ }
54+ return
55+ }
56+
57+ // Execute the root command
58+ if err := rootCmd.Execute(); err != nil {
59+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
60+ os.Exit(1)
61+ }
62+}⏎̸
A dumb-browser +0 -0 binary file: no text diff

binary file: no text diff

M go.mod +8 -13
@@ -1,32 +1,27 @@
1-module dumb-browser1+module github.com/bnema/dumber
2 2
3 go 1.25.13 go 1.25.1
4 4
5 require (5 require (
6- github.com/fsnotify/fsnotify v1.9.0 // indirect6+ github.com/fsnotify/fsnotify v1.9.0
7- github.com/gabriel-vasile/mimetype v1.4.8 // indirect7+ github.com/ncruces/go-sqlite3 v0.29.0
8- github.com/go-playground/locales v0.14.1 // indirect8+ github.com/spf13/cobra v1.10.1
9- github.com/go-playground/universal-translator v0.18.1 // indirect9+ github.com/spf13/viper v1.21.0
10- github.com/go-playground/validator/v10 v10.27.0 // indirect10+)
11+
12+require (
11 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect13 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
12 github.com/inconshreveable/mousetrap v1.1.0 // indirect14 github.com/inconshreveable/mousetrap v1.1.0 // indirect
13- github.com/leodido/go-urn v1.4.0 // indirect
14- github.com/ncruces/go-sqlite3 v0.29.0 // indirect
15 github.com/ncruces/julianday v1.0.0 // indirect15 github.com/ncruces/julianday v1.0.0 // indirect
16 github.com/pelletier/go-toml/v2 v2.2.4 // indirect16 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
17 github.com/sagikazarmark/locafero v0.11.0 // indirect17 github.com/sagikazarmark/locafero v0.11.0 // indirect
18 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect18 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
19 github.com/spf13/afero v1.15.0 // indirect19 github.com/spf13/afero v1.15.0 // indirect
20 github.com/spf13/cast v1.10.0 // indirect20 github.com/spf13/cast v1.10.0 // indirect
21- github.com/spf13/cobra v1.10.1 // indirect
22 github.com/spf13/pflag v1.0.10 // indirect21 github.com/spf13/pflag v1.0.10 // indirect
23- github.com/spf13/viper v1.21.0 // indirect
24- github.com/stretchr/testify v1.11.1 // indirect
25 github.com/subosito/gotenv v1.6.0 // indirect22 github.com/subosito/gotenv v1.6.0 // indirect
26 github.com/tetratelabs/wazero v1.9.0 // indirect23 github.com/tetratelabs/wazero v1.9.0 // indirect
27 go.yaml.in/yaml/v3 v3.0.4 // indirect24 go.yaml.in/yaml/v3 v3.0.4 // indirect
28- golang.org/x/crypto v0.41.0 // indirect
29- golang.org/x/net v0.42.0 // indirect
30 golang.org/x/sys v0.36.0 // indirect25 golang.org/x/sys v0.36.0 // indirect
31 golang.org/x/text v0.29.0 // indirect26 golang.org/x/text v0.29.0 // indirect
32 )27 )
M go.sum +17 -18 generated file: collapsed
A internal/cli/browse.go +196 -0
@@ -0,0 +1,196 @@
1+package cli
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "net/url"
8+ "os"
9+ "os/exec"
10+ "regexp"
11+ "runtime"
12+ "strings"
13+
14+ "github.com/bnema/dumber/internal/config"
15+
16+ "github.com/spf13/cobra"
17+)
18+
19+// NewBrowseCmd creates the browse command
20+func NewBrowseCmd() *cobra.Command {
21+ cmd := &cobra.Command{
22+ Use: "browse <url|query>",
23+ Short: "Browse a URL or search query",
24+ Long: `Browse a URL directly or use search shortcuts like:
25+ g:golang -> Google search for "golang"
26+ gh:cobra -> GitHub search for "cobra"
27+ yt:tutorials -> YouTube search for "tutorials"
28+ w:go language -> Wikipedia search for "go language"
29+
30+Direct URLs are also supported:
31+ https://example.com
32+ example.com (automatically adds https://)`,
33+ Args: cobra.ExactArgs(1),
34+ RunE: func(_ *cobra.Command, args []string) error {
35+ cli, err := NewCLI()
36+ if err != nil {
37+ return fmt.Errorf("failed to initialize CLI: %w", err)
38+ }
39+ defer func() {
40+ if closeErr := cli.Close(); closeErr != nil {
41+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
42+ }
43+ }()
44+
45+ return browse(cli, args[0])
46+ },
47+ }
48+
49+ return cmd
50+}
51+
52+// browse handles the core browsing logic
53+func browse(cli *CLI, input string) error {
54+ ctx := context.Background()
55+
56+ // Parse the input to determine URL or shortcut
57+ finalURL, err := parseInput(ctx, cli, input)
58+ if err != nil {
59+ return fmt.Errorf("failed to parse input: %w", err)
60+ }
61+
62+ // Record in history
63+ if err := recordVisit(ctx, cli, finalURL, ""); err != nil {
64+ // Don't fail the browse operation if history recording fails
65+ fmt.Fprintf(os.Stderr, "Warning: failed to record history: %v\n", err)
66+ }
67+
68+ // Open URL using configuration
69+ return openURLWithConfig(finalURL, cli.Config)
70+}
71+
72+// parseInput processes user input and returns the final URL to browse
73+func parseInput(ctx context.Context, cli *CLI, input string) (string, error) {
74+ // Check if it's a shortcut (format: "prefix:query")
75+ if strings.Contains(input, ":") && !strings.Contains(input, "://") {
76+ parts := strings.SplitN(input, ":", 2) //nolint:mnd // split on first colon only
77+ if len(parts) == 2 { //nolint:mnd // expect prefix and query parts
78+ shortcut := strings.TrimSpace(parts[0])
79+ query := strings.TrimSpace(parts[1])
80+
81+ if query == "" {
82+ return "", fmt.Errorf("empty query for shortcut '%s'", shortcut)
83+ }
84+
85+ // First check configuration-based shortcuts
86+ if shortcutCfg, exists := cli.Config.SearchShortcuts[shortcut]; exists {
87+ return fmt.Sprintf(shortcutCfg.URL, url.QueryEscape(query)), nil
88+ }
89+
90+ // Fallback to database shortcuts for backward compatibility
91+ shortcuts, err := cli.Queries.GetShortcuts(ctx)
92+ if err != nil {
93+ return "", fmt.Errorf("failed to get shortcuts: %w", err)
94+ }
95+
96+ for _, s := range shortcuts {
97+ if s.Shortcut == shortcut {
98+ return fmt.Sprintf(s.UrlTemplate, url.QueryEscape(query)), nil
99+ }
100+ }
101+
102+ return "", fmt.Errorf("unknown shortcut '%s'", shortcut)
103+ }
104+ }
105+
106+ // Check if it's already a valid URL
107+ if isValidURL(input) {
108+ return input, nil
109+ }
110+
111+ // Try to make it a URL by adding https://
112+ if !strings.Contains(input, "://") {
113+ candidate := "https://" + input
114+ if isValidURL(candidate) {
115+ return candidate, nil
116+ }
117+ }
118+
119+ // If all else fails, search Google for it
120+ return fmt.Sprintf("https://www.google.com/search?q=%s", url.QueryEscape(input)), nil
121+}
122+
123+// isValidURL checks if a string is a valid URL
124+func isValidURL(str string) bool {
125+ u, err := url.Parse(str)
126+ if err != nil {
127+ return false
128+ }
129+
130+ // Must have a scheme and host
131+ if u.Scheme == "" || u.Host == "" {
132+ return false
133+ }
134+
135+ // Must be http or https
136+ if u.Scheme != "http" && u.Scheme != "https" {
137+ return false
138+ }
139+
140+ // Basic domain validation
141+ domainRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?` +
142+ `(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
143+ return domainRegex.MatchString(u.Host)
144+}
145+
146+// recordVisit adds or updates a URL visit in the history
147+func recordVisit(ctx context.Context, cli *CLI, url, title string) error {
148+ var sqlTitle sql.NullString
149+ if title != "" {
150+ sqlTitle = sql.NullString{String: title, Valid: true}
151+ }
152+
153+ return cli.Queries.AddOrUpdateHistory(ctx, url, sqlTitle)
154+}
155+
156+// openURL opens a URL using the configured browser
157+func openURL(url string) error {
158+ return openURLWithConfig(url, nil)
159+}
160+
161+// openURLWithConfig opens a URL using system default browser
162+// TODO: Replace with Wails browser integration
163+func openURLWithConfig(url string, _ *config.Config) error {
164+ var cmd *exec.Cmd
165+
166+ // Use system default browser until Wails integration is ready
167+ switch runtime.GOOS {
168+ case "linux":
169+ cmd = exec.Command("xdg-open", url)
170+ case "darwin":
171+ cmd = exec.Command("open", url)
172+ case "windows":
173+ cmd = exec.Command("cmd", "/c", "start", "", url)
174+ default:
175+ return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
176+ }
177+
178+ // Start the browser in detached mode
179+ if err := cmd.Start(); err != nil {
180+ return fmt.Errorf("failed to open URL in browser: %w", err)
181+ }
182+
183+ // Don't wait for the browser process to exit
184+ go func() {
185+ _ = cmd.Wait()
186+ }()
187+
188+ fmt.Printf("Opening: %s (using system default browser)\n", url)
189+ return nil
190+}
191+
192+// commandExists checks if a command is available in the system PATH
193+func commandExists(cmd string) bool {
194+ _, err := exec.LookPath(cmd)
195+ return err == nil
196+}
A internal/cli/dmenu.go +218 -0
@@ -0,0 +1,218 @@
1+package cli
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "os"
8+ "sort"
9+ "strings"
10+
11+ "github.com/spf13/cobra"
12+)
13+
14+// NewDmenuCmd creates the dmenu command for launcher integration
15+func NewDmenuCmd() *cobra.Command {
16+ cmd := &cobra.Command{
17+ Use: "dmenu",
18+ Short: "Run in dmenu mode for launcher integration",
19+ Long: `Run in dmenu mode to integrate with rofi, dmenu, or other launchers.
20+This mode reads from stdin and outputs selectable options to stdout.
21+
22+Usage with rofi:
23+ dumber dmenu | rofi -dmenu -p "Browse: " | dumber dmenu --select
24+
25+Usage with dmenu:
26+ dumber dmenu | dmenu -p "Browse: " | dumber dmenu --select`,
27+ RunE: func(cmd *cobra.Command, _ []string) error {
28+ selectFlag := cmd.Flag("select").Changed
29+
30+ cli, err := NewCLI()
31+ if err != nil {
32+ return fmt.Errorf("failed to initialize CLI: %w", err)
33+ }
34+ defer func() {
35+ if closeErr := cli.Close(); closeErr != nil {
36+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
37+ }
38+ }()
39+
40+ if selectFlag {
41+ return handleSelection(cli)
42+ }
43+ return generateOptions(cli)
44+ },
45+ }
46+
47+ cmd.Flags().Bool("select", false, "Process selection from launcher (reads from stdin)")
48+ return cmd
49+}
50+
51+// generateOptions outputs all available options for the launcher
52+func generateOptions(cli *CLI) error {
53+ ctx := context.Background()
54+
55+ const maxHistoryEntries = 50
56+ // Get history entries
57+ history, err := cli.Queries.GetHistory(ctx, maxHistoryEntries)
58+ if err != nil {
59+ return fmt.Errorf("failed to get history: %w", err)
60+ }
61+
62+ // Get shortcuts
63+ shortcuts, err := cli.Queries.GetShortcuts(ctx)
64+ if err != nil {
65+ return fmt.Errorf("failed to get shortcuts: %w", err)
66+ }
67+
68+ // Generate options
69+ options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1)
70+
71+ // Add direct URL input option
72+ options = append(options, DmenuOption{
73+ Display: "🌐 Enter URL or search query...",
74+ Value: "",
75+ Type: "input",
76+ Description: "Type any URL or search term",
77+ })
78+
79+ // Add shortcuts with examples
80+ for _, shortcut := range shortcuts {
81+ desc := shortcut.Description.String
82+ if !shortcut.Description.Valid {
83+ desc = "Custom shortcut"
84+ }
85+
86+ options = append(options, DmenuOption{
87+ Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc),
88+ Value: shortcut.Shortcut + ":",
89+ Type: "shortcut",
90+ Description: desc,
91+ })
92+ }
93+
94+ // Add history entries
95+ for _, entry := range history {
96+ title := entry.Url
97+ if entry.Title.Valid && entry.Title.String != "" {
98+ title = entry.Title.String
99+ }
100+
101+ const maxDisplayLength = 80
102+ // Truncate long titles/URLs for display
103+ display := truncateString(title, maxDisplayLength)
104+
105+ options = append(options, DmenuOption{
106+ Display: fmt.Sprintf("🕒 %s", display),
107+ Value: entry.Url,
108+ Type: "history",
109+ Description: entry.Url,
110+ })
111+ }
112+
113+ // Sort options: input first, then shortcuts, then history by visit count/recency
114+ sort.Slice(options, func(i, j int) bool {
115+ if options[i].Type != options[j].Type {
116+ // Order: input, shortcut, history
117+ typeOrder := map[string]int{"input": 0, "shortcut": 1, "history": 2} //nolint:mnd
118+ return typeOrder[options[i].Type] < typeOrder[options[j].Type]
119+ }
120+
121+ if options[i].Type == "history" && options[j].Type == "history" {
122+ // History already sorted by recency from database
123+ return i < j
124+ }
125+
126+ return options[i].Display < options[j].Display
127+ })
128+
129+ // Output options to stdout
130+ for _, option := range options {
131+ fmt.Println(option.Display)
132+ }
133+
134+ return nil
135+}
136+
137+// handleSelection processes the user's selection from the launcher
138+func handleSelection(cli *CLI) error {
139+ scanner := bufio.NewScanner(os.Stdin)
140+ if !scanner.Scan() {
141+ return fmt.Errorf("no input received")
142+ }
143+
144+ selection := strings.TrimSpace(scanner.Text())
145+ if selection == "" {
146+ return fmt.Errorf("empty selection")
147+ }
148+
149+ // Parse the selection to get the actual input
150+ input := parseSelection(selection)
151+
152+ // If it's just the input prompt, read another line for the actual input
153+ if input == "" || input == "Enter URL or search query..." {
154+ fmt.Print("Enter URL or search query: ")
155+ if !scanner.Scan() {
156+ return fmt.Errorf("no input received")
157+ }
158+ input = strings.TrimSpace(scanner.Text())
159+ }
160+
161+ if input == "" {
162+ return fmt.Errorf("empty input")
163+ }
164+
165+ // Browse the selected/entered URL
166+ return browse(cli, input)
167+}
168+
169+// DmenuOption represents a selectable option in dmenu mode
170+type DmenuOption struct {
171+ Display string
172+ Value string
173+ Type string // "input", "shortcut", "history"
174+ Description string
175+}
176+
177+// parseSelection extracts the actual URL/query from a dmenu selection
178+func parseSelection(selection string) string {
179+ // Remove emoji prefixes and clean up the selection
180+ selection = strings.TrimSpace(selection)
181+
182+ // Handle different option types
183+ if strings.HasPrefix(selection, "🌐 ") {
184+ // Input option selected
185+ return ""
186+ }
187+
188+ if strings.HasPrefix(selection, "🔍 ") {
189+ // Shortcut selected - extract the shortcut prefix
190+ rest := strings.TrimPrefix(selection, "🔍 ")
191+ if idx := strings.Index(rest, ":"); idx > 0 {
192+ return rest[:idx+1] // Return "g:" format
193+ }
194+ }
195+
196+ if strings.HasPrefix(selection, "🕒 ") {
197+ // History entry selected - this is the display title, need to extract URL
198+ // For now, we'll assume the display is the URL or close enough
199+ return strings.TrimPrefix(selection, "🕒 ")
200+ }
201+
202+ // Fallback: return the selection as-is
203+ return selection
204+}
205+
206+// truncateString truncates a string to the specified length with ellipsis
207+func truncateString(s string, maxLen int) string {
208+ if len(s) <= maxLen {
209+ return s
210+ }
211+
212+ const minEllipsisLength = 3
213+ if maxLen <= minEllipsisLength {
214+ return s[:maxLen]
215+ }
216+
217+ return s[:maxLen-minEllipsisLength] + "..."
218+}
A internal/cli/history.go +380 -0
@@ -0,0 +1,380 @@
1+package cli
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "os"
8+ "strconv"
9+ "strings"
10+ "text/tabwriter"
11+ "time"
12+
13+ "github.com/spf13/cobra"
14+)
15+
16+const (
17+ unknownValue = "Unknown"
18+ defaultHistoryLimit = 20
19+ defaultSearchLimit = 10
20+ maxURLDisplay = 50
21+ maxTitleDisplay = 40
22+ maxURLCompact = 60
23+ maxTitleCompact = 30
24+ tabSpacing = 2
25+)
26+
27+// NewHistoryCmd creates the history command
28+func NewHistoryCmd() *cobra.Command {
29+ cmd := &cobra.Command{
30+ Use: "history",
31+ Short: "Manage browsing history",
32+ Long: `Manage browsing history with various subcommands:
33+ list - Show recent browsing history
34+ search - Search through history
35+ clear - Clear history (with confirmation)`,
36+ RunE: func(cmd *cobra.Command, args []string) error {
37+ // Default to list if no subcommand
38+ return listHistory(cmd, args)
39+ },
40+ }
41+
42+ // List subcommand (also default behavior)
43+ listCmd := &cobra.Command{
44+ Use: "list",
45+ Short: "List recent browsing history",
46+ Long: `Display recent browsing history with visit counts and timestamps.`,
47+ RunE: listHistory,
48+ }
49+ listCmd.Flags().IntP("limit", "n", defaultHistoryLimit, "Number of history entries to show")
50+ listCmd.Flags().BoolP("verbose", "v", false, "Show detailed information")
51+
52+ // Search subcommand
53+ searchCmd := &cobra.Command{
54+ Use: "search <query>",
55+ Short: "Search through browsing history",
56+ Long: `Search for URLs and titles in browsing history.`,
57+ Args: cobra.ExactArgs(1),
58+ RunE: searchHistory,
59+ }
60+ searchCmd.Flags().IntP("limit", "n", defaultSearchLimit, "Number of search results to show")
61+
62+ // Clear subcommand
63+ clearCmd := &cobra.Command{
64+ Use: "clear",
65+ Short: "Clear browsing history",
66+ Long: `Clear all browsing history. This action cannot be undone.`,
67+ RunE: clearHistory,
68+ }
69+ clearCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
70+
71+ // Stats subcommand
72+ statsCmd := &cobra.Command{
73+ Use: "stats",
74+ Short: "Show history statistics",
75+ Long: `Display statistics about your browsing history.`,
76+ RunE: showStats,
77+ }
78+
79+ // Add global flags
80+ cmd.Flags().IntP("limit", "n", defaultHistoryLimit, "Number of history entries to show")
81+ cmd.Flags().BoolP("verbose", "v", false, "Show detailed information")
82+
83+ // Add subcommands
84+ cmd.AddCommand(listCmd)
85+ cmd.AddCommand(searchCmd)
86+ cmd.AddCommand(clearCmd)
87+ cmd.AddCommand(statsCmd)
88+
89+ return cmd
90+}
91+
92+// listHistory displays recent browsing history
93+func listHistory(cmd *cobra.Command, _ []string) error {
94+ cli, err := NewCLI()
95+ if err != nil {
96+ return fmt.Errorf("failed to initialize CLI: %w", err)
97+ }
98+ defer func() {
99+ if err := cli.Close(); err != nil {
100+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
101+ }
102+ }()
103+
104+ ctx := context.Background()
105+
106+ limit, _ := cmd.Flags().GetInt("limit")
107+ verbose, _ := cmd.Flags().GetBool("verbose")
108+
109+ history, err := cli.Queries.GetHistory(ctx, int64(limit))
110+ if err != nil {
111+ return fmt.Errorf("failed to get history: %w", err)
112+ }
113+
114+ if len(history) == 0 {
115+ fmt.Println("No browsing history found.")
116+ return nil
117+ }
118+
119+ // Create tabwriter for aligned output
120+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
121+ defer w.Flush()
122+
123+ if verbose {
124+ fmt.Fprintln(w, "ID\tVISITS\tLAST VISITED\tURL\tTITLE")
125+ fmt.Fprintln(w, "--\t------\t------------\t---\t-----")
126+
127+ for _, entry := range history {
128+ visits := "1"
129+ if entry.VisitCount.Valid {
130+ visits = strconv.FormatInt(entry.VisitCount.Int64, 10)
131+ }
132+
133+ lastVisited := unknownValue
134+ if entry.LastVisited.Valid {
135+ lastVisited = entry.LastVisited.Time.Format("2006-01-02 15:04")
136+ }
137+
138+ title := entry.Title.String
139+ if !entry.Title.Valid || title == "" {
140+ title = "-"
141+ }
142+
143+ url := truncateString(entry.Url, 50)
144+ title = truncateString(title, 40)
145+
146+ fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n",
147+ entry.ID, visits, lastVisited, url, title)
148+ }
149+ } else {
150+ fmt.Fprintln(w, "VISITS\tLAST VISITED\tURL")
151+ fmt.Fprintln(w, "------\t------------\t---")
152+
153+ for _, entry := range history {
154+ visits := "1"
155+ if entry.VisitCount.Valid {
156+ visits = strconv.FormatInt(entry.VisitCount.Int64, 10)
157+ }
158+
159+ lastVisited := unknownValue
160+ if entry.LastVisited.Valid {
161+ // Show relative time for recent entries
162+ if time.Since(entry.LastVisited.Time) < 24*time.Hour {
163+ lastVisited = entry.LastVisited.Time.Format("15:04")
164+ } else {
165+ lastVisited = entry.LastVisited.Time.Format("Jan 02")
166+ }
167+ }
168+
169+ url := entry.Url
170+ if len(url) > 60 {
171+ url = url[:57] + "..."
172+ }
173+
174+ fmt.Fprintf(w, "%s\t%s\t%s\n", visits, lastVisited, url)
175+ }
176+ }
177+
178+ return nil
179+}
180+
181+// searchHistory searches through browsing history
182+func searchHistory(cmd *cobra.Command, args []string) error {
183+ cli, err := NewCLI()
184+ if err != nil {
185+ return fmt.Errorf("failed to initialize CLI: %w", err)
186+ }
187+ defer func() {
188+ if err := cli.Close(); err != nil {
189+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
190+ }
191+ }()
192+
193+ ctx := context.Background()
194+ query := args[0]
195+ limit, _ := cmd.Flags().GetInt("limit")
196+
197+ // Search both URL and title
198+ searchQuery := sql.NullString{String: query, Valid: true}
199+ results, err := cli.Queries.SearchHistory(ctx, searchQuery, searchQuery, int64(limit))
200+ if err != nil {
201+ return fmt.Errorf("failed to search history: %w", err)
202+ }
203+
204+ if len(results) == 0 {
205+ fmt.Printf("No history entries found matching '%s'.\n", query)
206+ return nil
207+ }
208+
209+ fmt.Printf("Found %d result(s) for '%s':\n\n", len(results), query)
210+
211+ // Create tabwriter for aligned output
212+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
213+ defer w.Flush()
214+
215+ fmt.Fprintln(w, "VISITS\tLAST VISITED\tURL\tTITLE")
216+ fmt.Fprintln(w, "------\t------------\t---\t-----")
217+
218+ for _, entry := range results {
219+ visits := "1"
220+ if entry.VisitCount.Valid {
221+ visits = strconv.FormatInt(entry.VisitCount.Int64, 10)
222+ }
223+
224+ lastVisited := "Unknown"
225+ if entry.LastVisited.Valid {
226+ lastVisited = entry.LastVisited.Time.Format("Jan 02 15:04")
227+ }
228+
229+ title := entry.Title.String
230+ if !entry.Title.Valid || title == "" {
231+ title = "-"
232+ }
233+
234+ url := truncateString(entry.Url, 50)
235+ title = truncateString(title, 30)
236+
237+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", visits, lastVisited, url, title)
238+ }
239+
240+ return nil
241+}
242+
243+// clearHistory clears all browsing history
244+func clearHistory(cmd *cobra.Command, _ []string) error {
245+ force, _ := cmd.Flags().GetBool("force")
246+
247+ // Confirmation unless --force is used
248+ if !force {
249+ fmt.Print("This will permanently delete all browsing history. Continue? [y/N]: ")
250+ var response string
251+ _, _ = fmt.Scanln(&response)
252+ response = strings.ToLower(strings.TrimSpace(response))
253+
254+ if response != "y" && response != "yes" {
255+ fmt.Println("Operation cancelled.")
256+ return nil
257+ }
258+ }
259+
260+ cli, err := NewCLI()
261+ if err != nil {
262+ return fmt.Errorf("failed to initialize CLI: %w", err)
263+ }
264+ defer func() {
265+ if err := cli.Close(); err != nil {
266+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
267+ }
268+ }()
269+
270+ // Execute DELETE query
271+ result, err := cli.DB.Exec("DELETE FROM history")
272+ if err != nil {
273+ return fmt.Errorf("failed to clear history: %w", err)
274+ }
275+
276+ rowsAffected, err := result.RowsAffected()
277+ if err != nil {
278+ return fmt.Errorf("failed to get affected rows: %w", err)
279+ }
280+
281+ fmt.Printf("Successfully cleared %d history entries.\n", rowsAffected)
282+ return nil
283+}
284+
285+// showStats displays statistics about browsing history
286+func showStats(_ *cobra.Command, _ []string) error {
287+ cli, err := NewCLI()
288+ if err != nil {
289+ return fmt.Errorf("failed to initialize CLI: %w", err)
290+ }
291+ defer func() {
292+ if err := cli.Close(); err != nil {
293+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", err)
294+ }
295+ }()
296+
297+ ctx := context.Background()
298+
299+ // Get total entries
300+ var totalEntries int64
301+ err = cli.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM history").Scan(&totalEntries)
302+ if err != nil {
303+ return fmt.Errorf("failed to get total entries: %w", err)
304+ }
305+
306+ // Get total visits
307+ var totalVisits sql.NullInt64
308+ err = cli.DB.QueryRowContext(ctx, "SELECT SUM(visit_count) FROM history").Scan(&totalVisits)
309+ if err != nil {
310+ return fmt.Errorf("failed to get total visits: %w", err)
311+ }
312+
313+ // Get date range
314+ var oldestDate, newestDate sql.NullTime
315+ err = cli.DB.QueryRowContext(ctx, "SELECT MIN(created_at), MAX(last_visited) FROM history").Scan(&oldestDate, &newestDate)
316+ if err != nil {
317+ return fmt.Errorf("failed to get date range: %w", err)
318+ }
319+
320+ // Get top domains
321+ rows, err := cli.DB.QueryContext(ctx, `
322+ SELECT
323+ CASE
324+ WHEN url LIKE 'http://%' THEN substr(url, 8)
325+ WHEN url LIKE 'https://%' THEN substr(url, 9)
326+ ELSE url
327+ END as domain,
328+ COUNT(*) as count,
329+ SUM(visit_count) as total_visits
330+ FROM history
331+ GROUP BY domain
332+ ORDER BY total_visits DESC
333+ LIMIT 5
334+ `)
335+ if err != nil {
336+ return fmt.Errorf("failed to get top domains: %w", err)
337+ }
338+ defer rows.Close()
339+
340+ fmt.Println("Browsing History Statistics")
341+ fmt.Println("==========================")
342+ fmt.Printf("Total unique URLs: %d\n", totalEntries)
343+
344+ if totalVisits.Valid {
345+ fmt.Printf("Total visits: %d\n", totalVisits.Int64)
346+ }
347+
348+ if oldestDate.Valid && newestDate.Valid {
349+ fmt.Printf("Date range: %s to %s\n",
350+ oldestDate.Time.Format("2006-01-02"),
351+ newestDate.Time.Format("2006-01-02"))
352+ }
353+
354+ fmt.Println("\nTop 5 domains:")
355+ fmt.Println("--------------")
356+
357+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
358+ defer w.Flush()
359+
360+ fmt.Fprintln(w, "DOMAIN\tURLs\tVISITS")
361+ fmt.Fprintln(w, "------\t----\t------")
362+
363+ for rows.Next() {
364+ var domain string
365+ var urlCount, visitCount int64
366+
367+ if err := rows.Scan(&domain, &urlCount, &visitCount); err != nil {
368+ continue
369+ }
370+
371+ // Extract just the domain part
372+ if idx := strings.Index(domain, "/"); idx > 0 {
373+ domain = domain[:idx]
374+ }
375+
376+ fmt.Fprintf(w, "%s\t%d\t%d\n", domain, urlCount, visitCount)
377+ }
378+
379+ return nil
380+}⏎̸
A internal/cli/root.go +225 -0
@@ -0,0 +1,225 @@
1+// Package cli provides the command-line interface for dumber.
2+package cli
3+
4+import (
5+ "database/sql"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+
10+ "github.com/bnema/dumber/internal/config"
11+ "github.com/bnema/dumber/internal/db"
12+
13+ _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql
14+ _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite for cross-platform compatibility
15+ "github.com/spf13/cobra"
16+)
17+
18+// CLI holds the database connection, queries, and configuration for the CLI commands
19+type CLI struct {
20+ DB *sql.DB
21+ Queries *db.Queries
22+ Config *config.Config
23+}
24+
25+// NewCLI creates a new CLI instance with database connection and configuration
26+func NewCLI() (*CLI, error) {
27+ // Get configuration
28+ cfg := config.Get()
29+
30+ // Use configured database path
31+ dbPath := cfg.Database.Path
32+ if dbPath == "" {
33+ // Fallback to default path if not configured
34+ var err error
35+ dbPath, err = config.GetDatabaseFile()
36+ if err != nil {
37+ return nil, fmt.Errorf("failed to get database path: %w", err)
38+ }
39+ }
40+
41+ // Ensure database directory exists
42+ if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
43+ return nil, fmt.Errorf("failed to create database directory: %w", err)
44+ }
45+
46+ // Open database connection
47+ database, err := sql.Open("sqlite3", dbPath)
48+ if err != nil {
49+ return nil, fmt.Errorf("failed to open database: %w", err)
50+ }
51+
52+ // Test the connection
53+ if err := database.Ping(); err != nil {
54+ database.Close()
55+ return nil, fmt.Errorf("failed to connect to database: %w", err)
56+ }
57+
58+ // Initialize database schema with configuration-based shortcuts
59+ if err := initializeDatabase(database, cfg); err != nil {
60+ database.Close()
61+ return nil, fmt.Errorf("failed to initialize database: %w", err)
62+ }
63+
64+ return &CLI{
65+ DB: database,
66+ Queries: db.New(database),
67+ Config: cfg,
68+ }, nil
69+}
70+
71+// Close closes the database connection
72+func (c *CLI) Close() error {
73+ if c.DB != nil {
74+ return c.DB.Close()
75+ }
76+ return nil
77+}
78+
79+// NewRootCmd creates the root command for dumber
80+func NewRootCmd(version, commit, buildDate string) *cobra.Command {
81+ rootCmd := &cobra.Command{
82+ Use: "dumber [url]",
83+ Short: "A dumb browser for Wayland window managers",
84+ Long: `A fast, simple browser with rofi/dmenu integration for sway and hyprland window managers.`,
85+ Args: cobra.MaximumNArgs(1),
86+ RunE: func(cmd *cobra.Command, args []string) error {
87+ // Initialize CLI
88+ cli, err := NewCLI()
89+ if err != nil {
90+ return fmt.Errorf("failed to initialize CLI: %w", err)
91+ }
92+ defer func() {
93+ if closeErr := cli.Close(); closeErr != nil {
94+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
95+ }
96+ }()
97+
98+ // If URL provided, browse it directly
99+ if len(args) > 0 {
100+ return browse(cli, args[0])
101+ }
102+
103+ // Otherwise show help
104+ return cmd.Help()
105+ },
106+ }
107+
108+ // Add dmenu flag for launcher integration
109+ rootCmd.Flags().Bool("dmenu", false, "Run in dmenu mode for launcher integration")
110+
111+ // Version command
112+ versionCmd := &cobra.Command{
113+ Use: "version",
114+ Short: "Print version information",
115+ Run: func(_ *cobra.Command, _ []string) {
116+ fmt.Printf("dumber %s\n", version)
117+ fmt.Printf("commit: %s\n", commit)
118+ fmt.Printf("built: %s\n", buildDate)
119+ },
120+ }
121+
122+ // Init command (enhanced)
123+ initCmd := &cobra.Command{
124+ Use: "init",
125+ Short: "Initialize dumber database and configuration",
126+ RunE: func(_ *cobra.Command, _ []string) error {
127+ cli, err := NewCLI()
128+ if err != nil {
129+ return fmt.Errorf("failed to initialize CLI: %w", err)
130+ }
131+ defer func() {
132+ if closeErr := cli.Close(); closeErr != nil {
133+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
134+ }
135+ }()
136+
137+ fmt.Printf("dumber %s - Initialization complete!\n", version)
138+ fmt.Println("Database initialized at:", cli.Config.Database.Path)
139+
140+ // Show XDG directories
141+ xdgDirs, err := config.GetXDGDirs()
142+ if err == nil {
143+ fmt.Println("Configuration directories:")
144+ fmt.Printf("- Config: %s\n", xdgDirs.ConfigHome)
145+ fmt.Printf("- Data: %s\n", xdgDirs.DataHome)
146+ fmt.Printf("- State: %s\n", xdgDirs.StateHome)
147+ }
148+
149+ // Show configured shortcuts
150+ fmt.Println("Configured shortcuts:")
151+ for shortcut, cfg := range cli.Config.SearchShortcuts {
152+ fmt.Printf("- %s:%s -> %s\n", shortcut, "query", cfg.Description)
153+ }
154+ return nil
155+ },
156+ }
157+
158+ // Add subcommands
159+ rootCmd.AddCommand(versionCmd)
160+ rootCmd.AddCommand(initCmd)
161+ rootCmd.AddCommand(NewBrowseCmd())
162+ rootCmd.AddCommand(NewDmenuCmd())
163+ rootCmd.AddCommand(NewHistoryCmd())
164+
165+ return rootCmd
166+}
167+
168+// getDatabasePath returns the path to the SQLite database file (legacy function for compatibility)
169+func getDatabasePath() (string, error) {
170+ return config.GetDatabaseFile()
171+}
172+
173+// getDatabasePathOrDefault returns the database path or a default message (legacy function for compatibility)
174+func getDatabasePathOrDefault() string {
175+ path, err := config.GetDatabaseFile()
176+ if err != nil {
177+ return "~/.local/state/dumber/history.db"
178+ }
179+ return path
180+}
181+
182+// initializeDatabase creates the database schema if it doesn't exist
183+func initializeDatabase(db *sql.DB, cfg *config.Config) error {
184+ schema := `
185+ -- History tracking for visited URLs
186+ CREATE TABLE IF NOT EXISTS history (
187+ id INTEGER PRIMARY KEY AUTOINCREMENT,
188+ url TEXT NOT NULL UNIQUE,
189+ title TEXT,
190+ visit_count INTEGER DEFAULT 1,
191+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
192+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
193+ );
194+
195+ CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
196+ CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
197+
198+ -- URL shortcuts configuration
199+ CREATE TABLE IF NOT EXISTS shortcuts (
200+ id INTEGER PRIMARY KEY AUTOINCREMENT,
201+ shortcut TEXT NOT NULL UNIQUE,
202+ url_template TEXT NOT NULL,
203+ description TEXT,
204+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
205+ );
206+ `
207+
208+ // Execute schema creation
209+ if _, err := db.Exec(schema); err != nil {
210+ return err
211+ }
212+
213+ // Insert configured shortcuts
214+ for shortcut, shortcutCfg := range cfg.SearchShortcuts {
215+ _, err := db.Exec(
216+ "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
217+ shortcut, shortcutCfg.URL, shortcutCfg.Description,
218+ )
219+ if err != nil {
220+ return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err)
221+ }
222+ }
223+
224+ return nil
225+}
A internal/config/config.go +373 -0
@@ -0,0 +1,373 @@
1+// Package config provides configuration management for dumber with Viper integration.
2+package config
3+
4+import (
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "github.com/fsnotify/fsnotify"
14+ "github.com/spf13/viper"
15+)
16+
17+// Config represents the complete configuration for dumber.
18+type Config struct {
19+ Database DatabaseConfig `mapstructure:"database" yaml:"database"`
20+ History HistoryConfig `mapstructure:"history" yaml:"history"`
21+ SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
22+ Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`
23+ Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
24+}
25+
26+// DatabaseConfig holds database-related configuration.
27+type DatabaseConfig struct {
28+ Path string `mapstructure:"path" yaml:"path"`
29+ MaxConnections int `mapstructure:"max_connections" yaml:"max_connections"`
30+ MaxIdleTime time.Duration `mapstructure:"max_idle_time" yaml:"max_idle_time"`
31+ QueryTimeout time.Duration `mapstructure:"query_timeout" yaml:"query_timeout"`
32+}
33+
34+// HistoryConfig holds history-related configuration.
35+type HistoryConfig struct {
36+ MaxEntries int `mapstructure:"max_entries" yaml:"max_entries"`
37+ RetentionPeriodDays int `mapstructure:"retention_period_days" yaml:"retention_period_days"`
38+ CleanupIntervalDays int `mapstructure:"cleanup_interval_days" yaml:"cleanup_interval_days"`
39+}
40+
41+// SearchShortcut represents a search shortcut configuration.
42+type SearchShortcut struct {
43+ URL string `mapstructure:"url" yaml:"url"`
44+ Description string `mapstructure:"description" yaml:"description"`
45+}
46+
47+// DmenuConfig holds dmenu/rofi integration configuration.
48+type DmenuConfig struct {
49+ MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items"`
50+ ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count"`
51+ ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited"`
52+ HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix"`
53+ ShortcutPrefix string `mapstructure:"shortcut_prefix" yaml:"shortcut_prefix"`
54+ URLPrefix string `mapstructure:"url_prefix" yaml:"url_prefix"`
55+ DateFormat string `mapstructure:"date_format" yaml:"date_format"`
56+ SortByVisitCount bool `mapstructure:"sort_by_visit_count" yaml:"sort_by_visit_count"`
57+}
58+
59+// LoggingConfig holds logging configuration.
60+type LoggingConfig struct {
61+ Level string `mapstructure:"level" yaml:"level"`
62+ Format string `mapstructure:"format" yaml:"format"`
63+ Filename string `mapstructure:"filename" yaml:"filename"`
64+ MaxSize int `mapstructure:"max_size" yaml:"max_size"`
65+ MaxBackups int `mapstructure:"max_backups" yaml:"max_backups"`
66+ MaxAge int `mapstructure:"max_age" yaml:"max_age"`
67+ Compress bool `mapstructure:"compress" yaml:"compress"`
68+}
69+
70+// Manager handles configuration loading, watching, and reloading.
71+type Manager struct {
72+ config *Config
73+ viper *viper.Viper
74+ mu sync.RWMutex
75+ callbacks []func(*Config)
76+ watching bool
77+}
78+
79+// NewManager creates a new configuration manager.
80+func NewManager() (*Manager, error) {
81+ v := viper.New()
82+
83+ // Configure Viper - supports yaml, json, toml automatically
84+ v.SetConfigName("config") // Will find config.yaml, config.json, config.toml, etc.
85+
86+ // Add config paths
87+ configDir, err := GetConfigDir()
88+ if err != nil {
89+ return nil, fmt.Errorf("failed to get config directory: %w", err)
90+ }
91+ v.AddConfigPath(configDir)
92+ v.AddConfigPath(".") // Current directory for development
93+
94+ // Set up environment variable support
95+ v.SetEnvPrefix("DUMB_BROWSER")
96+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
97+ v.AutomaticEnv()
98+
99+ // Bind specific environment variables
100+ bindings := map[string]string{
101+ "database.path": "DATABASE_PATH",
102+ "database.max_connections": "DATABASE_MAX_CONNECTIONS",
103+ "database.max_idle_time": "DATABASE_MAX_IDLE_TIME",
104+ "database.query_timeout": "DATABASE_QUERY_TIMEOUT",
105+ "browser.command": "BROWSER_COMMAND",
106+ "browser.timeout": "BROWSER_TIMEOUT",
107+ "browser.detach_process": "BROWSER_DETACH_PROCESS",
108+ "history.max_entries": "HISTORY_MAX_ENTRIES",
109+ "history.retention_period": "HISTORY_RETENTION_PERIOD",
110+ "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL",
111+ "dmenu.max_history_items": "DMENU_MAX_HISTORY_ITEMS",
112+ "dmenu.show_visit_count": "DMENU_SHOW_VISIT_COUNT",
113+ "dmenu.show_last_visited": "DMENU_SHOW_LAST_VISITED",
114+ "dmenu.history_prefix": "DMENU_HISTORY_PREFIX",
115+ "dmenu.shortcut_prefix": "DMENU_SHORTCUT_PREFIX",
116+ "dmenu.url_prefix": "DMENU_URL_PREFIX",
117+ "dmenu.date_format": "DMENU_DATE_FORMAT",
118+ "dmenu.sort_by_visit_count": "DMENU_SORT_BY_VISIT_COUNT",
119+ "logging.level": "LOGGING_LEVEL",
120+ "logging.format": "LOGGING_FORMAT",
121+ "logging.filename": "LOGGING_FILENAME",
122+ "logging.max_size": "LOGGING_MAX_SIZE",
123+ "logging.max_backups": "LOGGING_MAX_BACKUPS",
124+ "logging.max_age": "LOGGING_MAX_AGE",
125+ "logging.compress": "LOGGING_COMPRESS",
126+ }
127+
128+ for key, env := range bindings {
129+ if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil {
130+ return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
131+ }
132+ }
133+
134+ return &Manager{
135+ viper: v,
136+ callbacks: make([]func(*Config), 0),
137+ }, nil
138+}
139+
140+// Load loads the configuration from file and environment variables.
141+func (m *Manager) Load() error {
142+ m.mu.Lock()
143+ defer m.mu.Unlock()
144+
145+ // Ensure directories exist
146+ if err := EnsureDirectories(); err != nil {
147+ return fmt.Errorf("failed to ensure directories: %w", err)
148+ }
149+
150+ // Set defaults
151+ m.setDefaults()
152+
153+ // Read config file if it exists
154+ if err := m.viper.ReadInConfig(); err != nil {
155+ if _, ok := err.(viper.ConfigFileNotFoundError); ok {
156+ // Config file not found, create default one
157+ if err := m.createDefaultConfig(); err != nil {
158+ return fmt.Errorf("failed to create default config: %w", err)
159+ }
160+ } else {
161+ return fmt.Errorf("failed to read config file: %w", err)
162+ }
163+ }
164+
165+ // Unmarshal into config struct
166+ config := &Config{}
167+ if err := m.viper.Unmarshal(config); err != nil {
168+ return fmt.Errorf("failed to unmarshal config: %w", err)
169+ }
170+
171+ // Set database path if not specified
172+ if config.Database.Path == "" {
173+ dbPath, err := GetDatabaseFile()
174+ if err != nil {
175+ return fmt.Errorf("failed to get database path: %w", err)
176+ }
177+ config.Database.Path = dbPath
178+ }
179+
180+ m.config = config
181+ return nil
182+}
183+
184+// Get returns the current configuration (thread-safe).
185+func (m *Manager) Get() *Config {
186+ m.mu.RLock()
187+ defer m.mu.RUnlock()
188+
189+ // Return a copy to prevent external modification
190+ configCopy := *m.config
191+ return &configCopy
192+}
193+
194+// Watch starts watching the config file for changes and reloads automatically.
195+func (m *Manager) Watch() error {
196+ m.mu.Lock()
197+ defer m.mu.Unlock()
198+
199+ if m.watching {
200+ return nil // Already watching
201+ }
202+
203+ m.viper.WatchConfig()
204+ m.viper.OnConfigChange(func(_ fsnotify.Event) {
205+ // Reload config
206+ if err := m.reload(); err != nil {
207+ fmt.Fprintf(os.Stderr, "Warning: failed to reload config: %v\n", err)
208+ return
209+ }
210+
211+ // Notify callbacks
212+ m.mu.RLock()
213+ config := m.config
214+ callbacks := make([]func(*Config), len(m.callbacks))
215+ copy(callbacks, m.callbacks)
216+ m.mu.RUnlock()
217+
218+ for _, callback := range callbacks {
219+ callback(config)
220+ }
221+ })
222+
223+ m.watching = true
224+ return nil
225+}
226+
227+// OnConfigChange registers a callback function to be called when config changes.
228+func (m *Manager) OnConfigChange(callback func(*Config)) {
229+ m.mu.Lock()
230+ defer m.mu.Unlock()
231+
232+ m.callbacks = append(m.callbacks, callback)
233+}
234+
235+// reload reloads the configuration (internal method, must be called with lock).
236+func (m *Manager) reload() error {
237+ if err := m.viper.ReadInConfig(); err != nil {
238+ return err
239+ }
240+
241+ config := &Config{}
242+ if err := m.viper.Unmarshal(config); err != nil {
243+ return err
244+ }
245+
246+ // Set database path if not specified
247+ if config.Database.Path == "" {
248+ dbPath, err := GetDatabaseFile()
249+ if err != nil {
250+ return fmt.Errorf("failed to get database path: %w", err)
251+ }
252+ config.Database.Path = dbPath
253+ }
254+
255+ m.config = config
256+ return nil
257+}
258+
259+// setDefaults sets default configuration values in Viper.
260+func (m *Manager) setDefaults() {
261+ defaults := DefaultConfig()
262+
263+ // Database defaults
264+ m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections)
265+ m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime)
266+ m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout)
267+
268+ // Note: Browser config removed - we build our own browser with Wails
269+
270+ // History defaults
271+ m.viper.SetDefault("history.max_entries", defaults.History.MaxEntries)
272+ m.viper.SetDefault("history.retention_period_days", defaults.History.RetentionPeriodDays)
273+ m.viper.SetDefault("history.cleanup_interval_days", defaults.History.CleanupIntervalDays)
274+
275+ // Search shortcuts defaults
276+ m.viper.SetDefault("search_shortcuts", defaults.SearchShortcuts)
277+
278+ // Dmenu defaults
279+ m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems)
280+ m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount)
281+ m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited)
282+ m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix)
283+ m.viper.SetDefault("dmenu.shortcut_prefix", defaults.Dmenu.ShortcutPrefix)
284+ m.viper.SetDefault("dmenu.url_prefix", defaults.Dmenu.URLPrefix)
285+ m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
286+ m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
287+
288+ // Logging defaults
289+ m.viper.SetDefault("logging.level", defaults.Logging.Level)
290+ m.viper.SetDefault("logging.format", defaults.Logging.Format)
291+ m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
292+ m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
293+ m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
294+ m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
295+ m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
296+}
297+
298+// createDefaultConfig creates a default configuration file.
299+func (m *Manager) createDefaultConfig() error {
300+ configFile, err := GetConfigFile()
301+ if err != nil {
302+ return err
303+ }
304+
305+ // Create config directory if it doesn't exist
306+ if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil {
307+ return err
308+ }
309+
310+ // Get the default configuration
311+ defaultConfig := DefaultConfig()
312+
313+ // Marshal to JSON with proper indentation
314+ configData, err := json.MarshalIndent(defaultConfig, "", " ")
315+ if err != nil {
316+ return fmt.Errorf("failed to marshal default config: %w", err)
317+ }
318+
319+ // Write JSON config file
320+ if err := os.WriteFile(configFile, configData, 0644); err != nil {
321+ return fmt.Errorf("failed to write config file: %w", err)
322+ }
323+
324+ fmt.Printf("Created default configuration file: %s\n", configFile)
325+ return nil
326+}
327+
328+// GetConfigFile returns the path to the configuration file being used.
329+func (m *Manager) GetConfigFile() string {
330+ return m.viper.ConfigFileUsed()
331+}
332+
333+// Global configuration manager instance
334+var globalManager *Manager
335+var globalManagerOnce sync.Once
336+
337+// Init initializes the global configuration manager.
338+func Init() error {
339+ var err error
340+ globalManagerOnce.Do(func() {
341+ globalManager, err = NewManager()
342+ if err != nil {
343+ return
344+ }
345+ err = globalManager.Load()
346+ })
347+ return err
348+}
349+
350+// Get returns the global configuration.
351+func Get() *Config {
352+ if globalManager == nil {
353+ // Return defaults if not initialized
354+ return DefaultConfig()
355+ }
356+ return globalManager.Get()
357+}
358+
359+// Watch starts watching the global configuration for changes.
360+func Watch() error {
361+ if globalManager == nil {
362+ return fmt.Errorf("configuration not initialized")
363+ }
364+ return globalManager.Watch()
365+}
366+
367+// OnConfigChange registers a callback for global configuration changes.
368+func OnConfigChange(callback func(*Config)) {
369+ if globalManager == nil {
370+ return
371+ }
372+ globalManager.OnConfigChange(callback)
373+}
A internal/config/defaults.go +107 -0
@@ -0,0 +1,107 @@
1+// Package config provides default configuration values for dumber.
2+package config
3+
4+import (
5+ "time"
6+)
7+
8+// DefaultConfig returns the default configuration values for dumber.
9+func DefaultConfig() *Config {
10+ return &Config{
11+ Database: DatabaseConfig{
12+ MaxConnections: 1,
13+ MaxIdleTime: time.Minute * 5,
14+ QueryTimeout: time.Second * 30,
15+ },
16+ History: HistoryConfig{
17+ MaxEntries: 10000,
18+ RetentionPeriodDays: 365, // 1 year
19+ CleanupIntervalDays: 1, // daily cleanup
20+ },
21+ SearchShortcuts: map[string]SearchShortcut{
22+ "g": {
23+ URL: "https://www.google.com/search?q=%s",
24+ Description: "Google search",
25+ },
26+ "gh": {
27+ URL: "https://github.com/search?q=%s",
28+ Description: "GitHub search",
29+ },
30+ "yt": {
31+ URL: "https://www.youtube.com/results?search_query=%s",
32+ Description: "YouTube search",
33+ },
34+ "w": {
35+ URL: "https://en.wikipedia.org/wiki/%s",
36+ Description: "Wikipedia search",
37+ },
38+ },
39+ Dmenu: DmenuConfig{
40+ MaxHistoryItems: 20,
41+ ShowVisitCount: true,
42+ ShowLastVisited: true,
43+ HistoryPrefix: "🕒",
44+ ShortcutPrefix: "🔍",
45+ URLPrefix: "🌐",
46+ DateFormat: "2006-01-02 15:04",
47+ SortByVisitCount: true,
48+ },
49+ Logging: LoggingConfig{
50+ Level: "info",
51+ Format: "text", // text or json
52+ Filename: "", // empty means stdout
53+ MaxSize: 100, // MB
54+ MaxBackups: 3,
55+ MaxAge: 7, // days
56+ Compress: true,
57+ },
58+ }
59+}
60+
61+// GetDefaultSearchShortcuts returns the default search shortcuts.
62+func GetDefaultSearchShortcuts() map[string]SearchShortcut {
63+ return map[string]SearchShortcut{
64+ "g": {
65+ URL: "https://www.google.com/search?q=%s",
66+ Description: "Google search",
67+ },
68+ "gh": {
69+ URL: "https://github.com/search?q=%s",
70+ Description: "GitHub search",
71+ },
72+ "yt": {
73+ URL: "https://www.youtube.com/results?search_query=%s",
74+ Description: "YouTube search",
75+ },
76+ "w": {
77+ URL: "https://en.wikipedia.org/wiki/%s",
78+ Description: "Wikipedia search",
79+ },
80+ "ddg": {
81+ URL: "https://duckduckgo.com/?q=%s",
82+ Description: "DuckDuckGo search",
83+ },
84+ "so": {
85+ URL: "https://stackoverflow.com/search?q=%s",
86+ Description: "Stack Overflow search",
87+ },
88+ "r": {
89+ URL: "https://www.reddit.com/search?q=%s",
90+ Description: "Reddit search",
91+ },
92+ "npm": {
93+ URL: "https://www.npmjs.com/search?q=%s",
94+ Description: "npm package search",
95+ },
96+ "go": {
97+ URL: "https://pkg.go.dev/search?q=%s",
98+ Description: "Go package search",
99+ },
100+ "mdn": {
101+ URL: "https://developer.mozilla.org/en-US/search?q=%s",
102+ Description: "MDN Web Docs search",
103+ },
104+ }
105+}
106+
107+// Note: We build our own browser with Wails, so no external browser commands needed
A internal/config/xdg.go +123 -0
@@ -0,0 +1,123 @@
1+// Package config provides XDG Base Directory specification compliance utilities.
2+package config
3+
4+import (
5+ "os"
6+ "path/filepath"
7+)
8+
9+const appName = "dumber"
10+
11+// XDGDirs holds the XDG Base Directory paths for the application.
12+type XDGDirs struct {
13+ ConfigHome string
14+ DataHome string
15+ StateHome string
16+}
17+
18+// GetXDGDirs returns the XDG Base Directory paths for dumber.
19+// It follows the XDG Base Directory specification:
20+// - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber)
21+// - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber)
22+// - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber)
23+func GetXDGDirs() (*XDGDirs, error) {
24+ homeDir, err := os.UserHomeDir()
25+ if err != nil {
26+ return nil, err
27+ }
28+
29+ // XDG_CONFIG_HOME
30+ configHome := os.Getenv("XDG_CONFIG_HOME")
31+ if configHome == "" {
32+ configHome = filepath.Join(homeDir, ".config")
33+ }
34+ configHome = filepath.Join(configHome, appName)
35+
36+ // XDG_DATA_HOME
37+ dataHome := os.Getenv("XDG_DATA_HOME")
38+ if dataHome == "" {
39+ dataHome = filepath.Join(homeDir, ".local", "share")
40+ }
41+ dataHome = filepath.Join(dataHome, appName)
42+
43+ // XDG_STATE_HOME
44+ stateHome := os.Getenv("XDG_STATE_HOME")
45+ if stateHome == "" {
46+ stateHome = filepath.Join(homeDir, ".local", "state")
47+ }
48+ stateHome = filepath.Join(stateHome, appName)
49+
50+ return &XDGDirs{
51+ ConfigHome: configHome,
52+ DataHome: dataHome,
53+ StateHome: stateHome,
54+ }, nil
55+}
56+
57+// GetConfigDir returns the XDG config directory for dumber.
58+func GetConfigDir() (string, error) {
59+ dirs, err := GetXDGDirs()
60+ if err != nil {
61+ return "", err
62+ }
63+ return dirs.ConfigHome, nil
64+}
65+
66+// GetDataDir returns the XDG data directory for dumber.
67+func GetDataDir() (string, error) {
68+ dirs, err := GetXDGDirs()
69+ if err != nil {
70+ return "", err
71+ }
72+ return dirs.DataHome, nil
73+}
74+
75+// GetStateDir returns the XDG state directory for dumber.
76+func GetStateDir() (string, error) {
77+ dirs, err := GetXDGDirs()
78+ if err != nil {
79+ return "", err
80+ }
81+ return dirs.StateHome, nil
82+}
83+
84+// GetConfigFile returns the path to the main configuration file.
85+func GetConfigFile() (string, error) {
86+ configDir, err := GetConfigDir()
87+ if err != nil {
88+ return "", err
89+ }
90+ return filepath.Join(configDir, "config.json"), nil
91+}
92+
93+// GetDatabaseFile returns the path to the database file in the state directory.
94+func GetDatabaseFile() (string, error) {
95+ stateDir, err := GetStateDir()
96+ if err != nil {
97+ return "", err
98+ }
99+ return filepath.Join(stateDir, "history.db"), nil
100+}
101+
102+// EnsureDirectories creates the XDG directories if they don't exist.
103+func EnsureDirectories() error {
104+ dirs, err := GetXDGDirs()
105+ if err != nil {
106+ return err
107+ }
108+
109+ // Create all directories with proper permissions
110+ directories := []string{
111+ dirs.ConfigHome,
112+ dirs.DataHome,
113+ dirs.StateHome,
114+ }
115+
116+ for _, dir := range directories {
117+ if err := os.MkdirAll(dir, 0755); err != nil {
118+ return err
119+ }
120+ }
121+
122+ return nil
123+}
A internal/parser/fuzzy.go +471 -0
@@ -0,0 +1,471 @@
1+package parser
2+
3+import (
4+ "database/sql"
5+ "math"
6+ "sort"
7+ "strings"
8+ "time"
9+ "unicode/utf8"
10+
11+ "github.com/bnema/dumber/internal/db"
12+)
13+
14+// FuzzyMatcher implements efficient fuzzy string matching algorithms.
15+type FuzzyMatcher struct {
16+ config *FuzzyConfig
17+}
18+
19+// NewFuzzyMatcher creates a new FuzzyMatcher with the given configuration.
20+func NewFuzzyMatcher(config *FuzzyConfig) *FuzzyMatcher {
21+ return &FuzzyMatcher{
22+ config: config,
23+ }
24+}
25+
26+// SearchHistory performs fuzzy search on history entries and returns ranked matches.
27+func (fm *FuzzyMatcher) SearchHistory(query string, history []*db.History) []FuzzyMatch {
28+ if len(history) == 0 || query == "" {
29+ return nil
30+ }
31+
32+ query = strings.ToLower(strings.TrimSpace(query))
33+ matches := make([]FuzzyMatch, 0)
34+
35+ for _, entry := range history {
36+ match := fm.matchHistoryEntry(query, entry)
37+ if match.Score >= fm.config.MinSimilarityThreshold {
38+ matches = append(matches, match)
39+ }
40+ }
41+
42+ // Sort by score (descending)
43+ sort.Slice(matches, func(i, j int) bool {
44+ return matches[i].Score > matches[j].Score
45+ })
46+
47+ // Limit results
48+ if len(matches) > fm.config.MaxResults {
49+ matches = matches[:fm.config.MaxResults]
50+ }
51+
52+ return matches
53+}
54+
55+// matchHistoryEntry calculates fuzzy match score for a history entry.
56+func (fm *FuzzyMatcher) matchHistoryEntry(query string, entry *db.History) FuzzyMatch {
57+ match := FuzzyMatch{
58+ HistoryEntry: entry,
59+ }
60+
61+ url := strings.ToLower(entry.Url)
62+ title := ""
63+ if entry.Title.Valid {
64+ title = strings.ToLower(entry.Title.String)
65+ }
66+
67+ // Calculate URL similarity
68+ match.URLScore = fm.calculateSimilarity(query, url)
69+ urlSubstring := fm.substringMatch(query, url)
70+ if urlSubstring > match.URLScore {
71+ match.URLScore = urlSubstring
72+ }
73+
74+ // Calculate title similarity
75+ if title != "" {
76+ match.TitleScore = fm.calculateSimilarity(query, title)
77+ titleSubstring := fm.substringMatch(query, title)
78+ if titleSubstring > match.TitleScore {
79+ match.TitleScore = titleSubstring
80+ }
81+ }
82+
83+ // Calculate recency score
84+ match.RecencyScore = fm.calculateRecencyScore(entry.LastVisited)
85+
86+ // Calculate visit count score
87+ match.VisitScore = fm.calculateVisitScore(entry.VisitCount)
88+
89+ // Determine matched field
90+ if match.URLScore >= match.TitleScore {
91+ match.MatchedField = "url"
92+ if match.URLScore > 0 && match.TitleScore > 0 {
93+ match.MatchedField = "both"
94+ }
95+ } else {
96+ match.MatchedField = "title"
97+ }
98+
99+ // Calculate weighted final score
100+ match.Score = fm.config.URLWeight*match.URLScore +
101+ fm.config.TitleWeight*match.TitleScore +
102+ fm.config.RecencyWeight*match.RecencyScore +
103+ fm.config.VisitWeight*match.VisitScore
104+
105+ return match
106+}
107+
108+// calculateSimilarity computes similarity using Jaro-Winkler distance.
109+func (fm *FuzzyMatcher) calculateSimilarity(s1, s2 string) float64 {
110+ if s1 == s2 {
111+ return 1.0
112+ }
113+
114+ if len(s1) == 0 || len(s2) == 0 {
115+ return 0.0
116+ }
117+
118+ // Use Jaro-Winkler distance for better results
119+ return fm.jaroWinklerSimilarity(s1, s2)
120+}
121+
122+// substringMatch calculates substring match score with position weighting.
123+func (fm *FuzzyMatcher) substringMatch(query, text string) float64 {
124+ if query == "" || text == "" {
125+ return 0.0
126+ }
127+
128+ // Check for exact substring match
129+ index := strings.Index(text, query)
130+ if index == -1 {
131+ return 0.0
132+ }
133+
134+ // Base score for substring match
135+ baseScore := float64(len(query)) / float64(len(text))
136+
137+ // Boost score if match is at the beginning
138+ positionWeight := 1.0
139+ if index == 0 {
140+ positionWeight = 1.5
141+ } else if index < len(text)/3 {
142+ positionWeight = 1.2
143+ }
144+
145+ score := baseScore * positionWeight
146+ if score > 1.0 {
147+ score = 1.0
148+ }
149+
150+ return score
151+}
152+
153+// jaroWinklerSimilarity calculates Jaro-Winkler similarity between two strings.
154+func (fm *FuzzyMatcher) jaroWinklerSimilarity(s1, s2 string) float64 {
155+ jaroSim := fm.jaroSimilarity(s1, s2)
156+
157+ if jaroSim < 0.7 {
158+ return jaroSim
159+ }
160+
161+ // Calculate common prefix length (up to 4 characters)
162+ prefixLength := 0
163+ maxPrefix := 4
164+ minLen := len(s1)
165+ if len(s2) < minLen {
166+ minLen = len(s2)
167+ }
168+ if maxPrefix > minLen {
169+ maxPrefix = minLen
170+ }
171+
172+ for i := 0; i < maxPrefix; i++ {
173+ if s1[i] == s2[i] {
174+ prefixLength++
175+ } else {
176+ break
177+ }
178+ }
179+
180+ // Winkler modification
181+ return jaroSim + (0.1 * float64(prefixLength) * (1 - jaroSim))
182+}
183+
184+// jaroSimilarity calculates Jaro similarity between two strings.
185+func (fm *FuzzyMatcher) jaroSimilarity(s1, s2 string) float64 {
186+ len1 := utf8.RuneCountInString(s1)
187+ len2 := utf8.RuneCountInString(s2)
188+
189+ if len1 == 0 && len2 == 0 {
190+ return 1.0
191+ }
192+ if len1 == 0 || len2 == 0 {
193+ return 0.0
194+ }
195+
196+ // Convert to rune slices for proper Unicode handling
197+ runes1 := []rune(s1)
198+ runes2 := []rune(s2)
199+
200+ // Calculate match window
201+ matchWindow := (maxInt(len1, len2) / 2) - 1
202+ if matchWindow < 0 {
203+ matchWindow = 0
204+ }
205+
206+ // Track matches
207+ matches1 := make([]bool, len1)
208+ matches2 := make([]bool, len2)
209+ matchCount := 0
210+
211+ // Find matches
212+ for i := 0; i < len1; i++ {
213+ start := maxInt(0, i-matchWindow)
214+ end := minInt(i+matchWindow+1, len2)
215+
216+ for j := start; j < end; j++ {
217+ if matches2[j] || runes1[i] != runes2[j] {
218+ continue
219+ }
220+ matches1[i] = true
221+ matches2[j] = true
222+ matchCount++
223+ break
224+ }
225+ }
226+
227+ if matchCount == 0 {
228+ return 0.0
229+ }
230+
231+ // Count transpositions
232+ transpositions := 0
233+ k := 0
234+ for i := 0; i < len1; i++ {
235+ if !matches1[i] {
236+ continue
237+ }
238+ for !matches2[k] {
239+ k++
240+ }
241+ if runes1[i] != runes2[k] {
242+ transpositions++
243+ }
244+ k++
245+ }
246+
247+ // Calculate Jaro similarity
248+ return (float64(matchCount)/float64(len1) +
249+ float64(matchCount)/float64(len2) +
250+ float64(matchCount-transpositions/2)/float64(matchCount)) / 3.0
251+}
252+
253+// calculateRecencyScore calculates score based on how recently the entry was visited.
254+func (fm *FuzzyMatcher) calculateRecencyScore(lastVisited sql.NullTime) float64 {
255+ if !lastVisited.Valid {
256+ return 0.0
257+ }
258+
259+ now := time.Now()
260+ daysSince := now.Sub(lastVisited.Time).Hours() / 24
261+
262+ // Exponential decay based on days since last visit
263+ decayRate := float64(fm.config.RecencyDecayDays)
264+ score := math.Exp(-daysSince / decayRate)
265+
266+ return score
267+}
268+
269+// calculateVisitScore calculates score based on visit count with logarithmic scaling.
270+func (fm *FuzzyMatcher) calculateVisitScore(visitCount sql.NullInt64) float64 {
271+ if !visitCount.Valid || visitCount.Int64 <= 0 {
272+ return 0.0
273+ }
274+
275+ // Logarithmic scaling to prevent highly visited sites from dominating
276+ score := math.Log1p(float64(visitCount.Int64)) / math.Log1p(1000) // Normalize to max 1000 visits
277+ if score > 1.0 {
278+ score = 1.0
279+ }
280+
281+ return score
282+}
283+
284+// LevenshteinDistance calculates the Levenshtein distance between two strings.
285+func (fm *FuzzyMatcher) LevenshteinDistance(s1, s2 string) int {
286+ runes1 := []rune(s1)
287+ runes2 := []rune(s2)
288+
289+ len1 := len(runes1)
290+ len2 := len(runes2)
291+
292+ // Create a matrix to store distances
293+ matrix := make([][]int, len1+1)
294+ for i := range matrix {
295+ matrix[i] = make([]int, len2+1)
296+ }
297+
298+ // Initialize first row and column
299+ for i := 0; i <= len1; i++ {
300+ matrix[i][0] = i
301+ }
302+ for j := 0; j <= len2; j++ {
303+ matrix[0][j] = j
304+ }
305+
306+ // Fill the matrix
307+ for i := 1; i <= len1; i++ {
308+ for j := 1; j <= len2; j++ {
309+ cost := 1
310+ if runes1[i-1] == runes2[j-1] {
311+ cost = 0
312+ }
313+
314+ deletion := matrix[i-1][j] + 1
315+ insertion := matrix[i][j-1] + 1
316+ substitution := matrix[i-1][j-1] + cost
317+
318+ matrix[i][j] = minInt(deletion, minInt(insertion, substitution))
319+ }
320+ }
321+
322+ return matrix[len1][len2]
323+}
324+
325+// LevenshteinSimilarity converts Levenshtein distance to similarity score (0.0-1.0).
326+func (fm *FuzzyMatcher) LevenshteinSimilarity(s1, s2 string) float64 {
327+ if s1 == s2 {
328+ return 1.0
329+ }
330+
331+ distance := fm.LevenshteinDistance(s1, s2)
332+ maxLen := maxInt(utf8.RuneCountInString(s1), utf8.RuneCountInString(s2))
333+
334+ if maxLen == 0 {
335+ return 1.0
336+ }
337+
338+ return 1.0 - float64(distance)/float64(maxLen)
339+}
340+
341+// TokenizedMatch performs token-based fuzzy matching for better phrase matching.
342+func (fm *FuzzyMatcher) TokenizedMatch(query, text string) float64 {
343+ queryTokens := strings.Fields(strings.ToLower(query))
344+ textTokens := strings.Fields(strings.ToLower(text))
345+
346+ if len(queryTokens) == 0 || len(textTokens) == 0 {
347+ return 0.0
348+ }
349+
350+ totalScore := 0.0
351+ matchedTokens := 0
352+
353+ for _, queryToken := range queryTokens {
354+ bestScore := 0.0
355+ for _, textToken := range textTokens {
356+ score := fm.calculateSimilarity(queryToken, textToken)
357+ if score > bestScore {
358+ bestScore = score
359+ }
360+ }
361+ if bestScore >= 0.5 { // Threshold for token match
362+ totalScore += bestScore
363+ matchedTokens++
364+ }
365+ }
366+
367+ if matchedTokens == 0 {
368+ return 0.0
369+ }
370+
371+ // Average score weighted by matched token ratio
372+ avgScore := totalScore / float64(matchedTokens)
373+ tokenRatio := float64(matchedTokens) / float64(len(queryTokens))
374+
375+ return avgScore * tokenRatio
376+}
377+
378+// RankMatches re-ranks fuzzy matches based on additional criteria.
379+func (fm *FuzzyMatcher) RankMatches(matches []FuzzyMatch, query string) []FuzzyMatch {
380+ if len(matches) == 0 {
381+ return matches
382+ }
383+
384+ // Apply additional ranking factors
385+ for i := range matches {
386+ match := &matches[i]
387+
388+ // Boost exact matches
389+ if strings.Contains(strings.ToLower(match.HistoryEntry.Url), strings.ToLower(query)) {
390+ match.Score *= 1.2
391+ }
392+
393+ // Boost domain matches
394+ if fm.isDomainMatch(query, match.HistoryEntry.Url) {
395+ match.Score *= 1.15
396+ }
397+
398+ // Boost short URLs (likely more relevant)
399+ if len(match.HistoryEntry.Url) < 50 {
400+ match.Score *= 1.05
401+ }
402+ }
403+
404+ // Re-sort by updated scores
405+ sort.Slice(matches, func(i, j int) bool {
406+ return matches[i].Score > matches[j].Score
407+ })
408+
409+ return matches
410+}
411+
412+// isDomainMatch checks if the query matches the domain of the URL.
413+func (fm *FuzzyMatcher) isDomainMatch(query, url string) bool {
414+ query = strings.ToLower(strings.TrimSpace(query))
415+
416+ // Extract domain from URL
417+ domain := url
418+ if strings.HasPrefix(url, "http://") {
419+ domain = url[7:]
420+ } else if strings.HasPrefix(url, "https://") {
421+ domain = url[8:]
422+ }
423+
424+ // Remove path
425+ if idx := strings.Index(domain, "/"); idx != -1 {
426+ domain = domain[:idx]
427+ }
428+
429+ // Remove port
430+ if idx := strings.Index(domain, ":"); idx != -1 {
431+ domain = domain[:idx]
432+ }
433+
434+ domain = strings.ToLower(domain)
435+
436+ // Check for exact domain match
437+ if domain == query {
438+ return true
439+ }
440+
441+ // Check if query is part of domain (e.g., "github" in "github.com" or "www.github.com")
442+ if strings.HasSuffix(domain, "."+query) || strings.HasPrefix(domain, query+".") {
443+ return true
444+ }
445+
446+ // Check if query matches a significant part of domain (not just a substring)
447+ // Split domain by dots and check if any part matches query
448+ domainParts := strings.Split(domain, ".")
449+ for _, part := range domainParts {
450+ if part == query {
451+ return true
452+ }
453+ }
454+
455+ return false
456+}
457+
458+// Helper functions
459+func minInt(a, b int) int {
460+ if a < b {
461+ return a
462+ }
463+ return b
464+}
465+
466+func maxInt(a, b int) int {
467+ if a > b {
468+ return a
469+ }
470+ return b
471+}
A internal/parser/fuzzy_test.go +520 -0
@@ -0,0 +1,520 @@
1+package parser
2+
3+import (
4+ "database/sql"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/db"
9+)
10+
11+func TestFuzzyMatcher_JaroWinklerSimilarity(t *testing.T) {
12+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
13+
14+ tests := []struct {
15+ s1 string
16+ s2 string
17+ expected float64
18+ delta float64 // Acceptable difference for float comparison
19+ }{
20+ {"", "", 1.0, 0.0},
21+ {"", "test", 0.0, 0.0},
22+ {"test", "", 0.0, 0.0},
23+ {"test", "test", 1.0, 0.0},
24+ {"github", "github", 1.0, 0.0},
25+ {"github", "gihub", 0.95, 0.05}, // High similarity
26+ {"github", "bitbucket", 0.62, 0.15}, // Lower similarity
27+ {"stackoverflow", "stack", 0.87, 0.05}, // Partial match
28+ {"google", "goggle", 0.9, 0.1}, // Single char difference
29+ {"programming", "programing", 0.95, 0.05}, // Missing char
30+ }
31+
32+ for _, tt := range tests {
33+ t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) {
34+ result := fuzzyMatcher.jaroWinklerSimilarity(tt.s1, tt.s2)
35+
36+ if result < 0.0 || result > 1.0 {
37+ t.Errorf("jaroWinklerSimilarity() = %v, should be between 0.0 and 1.0", result)
38+ }
39+
40+ diff := result - tt.expected
41+ if diff < 0 {
42+ diff = -diff
43+ }
44+ if diff > tt.delta {
45+ t.Errorf("jaroWinklerSimilarity(%q, %q) = %v, want %v ±%v", tt.s1, tt.s2, result, tt.expected, tt.delta)
46+ }
47+ })
48+ }
49+}
50+
51+func TestFuzzyMatcher_LevenshteinDistance(t *testing.T) {
52+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
53+
54+ tests := []struct {
55+ s1 string
56+ s2 string
57+ expected int
58+ }{
59+ {"", "", 0},
60+ {"", "test", 4},
61+ {"test", "", 4},
62+ {"test", "test", 0},
63+ {"github", "github", 0},
64+ {"github", "gihub", 1}, // 1 deletion
65+ {"cat", "bat", 1}, // 1 substitution
66+ {"kitten", "sitting", 3}, // Classic example
67+ {"programming", "programing", 1}, // 1 deletion
68+ }
69+
70+ for _, tt := range tests {
71+ t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) {
72+ result := fuzzyMatcher.LevenshteinDistance(tt.s1, tt.s2)
73+ if result != tt.expected {
74+ t.Errorf("LevenshteinDistance(%q, %q) = %v, want %v", tt.s1, tt.s2, result, tt.expected)
75+ }
76+ })
77+ }
78+}
79+
80+func TestFuzzyMatcher_LevenshteinSimilarity(t *testing.T) {
81+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
82+
83+ tests := []struct {
84+ s1 string
85+ s2 string
86+ expected float64
87+ delta float64
88+ }{
89+ {"test", "test", 1.0, 0.0},
90+ {"", "", 1.0, 0.0},
91+ {"github", "gihub", 0.83, 0.05}, // 1 error out of 6 chars
92+ {"cat", "bat", 0.67, 0.05}, // 1 error out of 3 chars
93+ }
94+
95+ for _, tt := range tests {
96+ t.Run(tt.s1+"_vs_"+tt.s2, func(t *testing.T) {
97+ result := fuzzyMatcher.LevenshteinSimilarity(tt.s1, tt.s2)
98+
99+ if result < 0.0 || result > 1.0 {
100+ t.Errorf("LevenshteinSimilarity() = %v, should be between 0.0 and 1.0", result)
101+ }
102+
103+ diff := result - tt.expected
104+ if diff < 0 {
105+ diff = -diff
106+ }
107+ if diff > tt.delta {
108+ t.Errorf("LevenshteinSimilarity(%q, %q) = %v, want %v ±%v", tt.s1, tt.s2, result, tt.expected, tt.delta)
109+ }
110+ })
111+ }
112+}
113+
114+func TestFuzzyMatcher_SubstringMatch(t *testing.T) {
115+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
116+
117+ tests := []struct {
118+ query string
119+ text string
120+ expected float64
121+ delta float64
122+ }{
123+ {"", "test", 0.0, 0.0},
124+ {"test", "", 0.0, 0.0},
125+ {"github", "github.com", 0.9, 0.1}, // Full match at start gets boost
126+ {"hub", "github.com", 0.3, 0.1}, // Partial match
127+ {"git", "github.com", 0.45, 0.1}, // Match at beginning gets boost
128+ {"com", "github.com", 0.3, 0.1}, // Match at end
129+ {"xyz", "github.com", 0.0, 0.0}, // No match
130+ }
131+
132+ for _, tt := range tests {
133+ t.Run(tt.query+"_in_"+tt.text, func(t *testing.T) {
134+ result := fuzzyMatcher.substringMatch(tt.query, tt.text)
135+
136+ if result < 0.0 || result > 1.0 {
137+ t.Errorf("substringMatch() = %v, should be between 0.0 and 1.0", result)
138+ }
139+
140+ diff := result - tt.expected
141+ if diff < 0 {
142+ diff = -diff
143+ }
144+ if diff > tt.delta {
145+ t.Errorf("substringMatch(%q, %q) = %v, want %v ±%v", tt.query, tt.text, result, tt.expected, tt.delta)
146+ }
147+ })
148+ }
149+}
150+
151+func TestFuzzyMatcher_TokenizedMatch(t *testing.T) {
152+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
153+
154+ tests := []struct {
155+ query string
156+ text string
157+ expected float64
158+ delta float64
159+ }{
160+ {"go programming", "The Go Programming Language", 0.8, 0.2},
161+ {"stack overflow", "Stack Overflow - Where Developers Learn", 0.8, 0.2},
162+ {"github repo", "GitHub Repository", 0.94, 0.1},
163+ {"missing words", "Something completely different", 0.34, 0.1},
164+ }
165+
166+ for _, tt := range tests {
167+ t.Run(tt.query+"_vs_"+tt.text, func(t *testing.T) {
168+ result := fuzzyMatcher.TokenizedMatch(tt.query, tt.text)
169+
170+ if result < 0.0 || result > 1.0 {
171+ t.Errorf("TokenizedMatch() = %v, should be between 0.0 and 1.0", result)
172+ }
173+
174+ diff := result - tt.expected
175+ if diff < 0 {
176+ diff = -diff
177+ }
178+ if diff > tt.delta {
179+ t.Errorf("TokenizedMatch(%q, %q) = %v, want %v ±%v", tt.query, tt.text, result, tt.expected, tt.delta)
180+ }
181+ })
182+ }
183+}
184+
185+func TestFuzzyMatcher_SearchHistory(t *testing.T) {
186+ config := DefaultFuzzyConfig()
187+ config.MinSimilarityThreshold = 0.3
188+ config.MaxResults = 10
189+ fuzzyMatcher := NewFuzzyMatcher(config)
190+
191+ now := time.Now()
192+ history := []*db.History{
193+ {
194+ ID: 1,
195+ Url: "https://github.com",
196+ Title: sql.NullString{String: "GitHub", Valid: true},
197+ VisitCount: sql.NullInt64{Int64: 25, Valid: true},
198+ LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
199+ },
200+ {
201+ ID: 2,
202+ Url: "https://stackoverflow.com",
203+ Title: sql.NullString{String: "Stack Overflow", Valid: true},
204+ VisitCount: sql.NullInt64{Int64: 15, Valid: true},
205+ LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
206+ },
207+ {
208+ ID: 3,
209+ Url: "https://golang.org",
210+ Title: sql.NullString{String: "The Go Programming Language", Valid: true},
211+ VisitCount: sql.NullInt64{Int64: 8, Valid: true},
212+ LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
213+ },
214+ }
215+
216+ tests := []struct {
217+ name string
218+ query string
219+ expectMatches int
220+ expectFirst string // Expected first match URL
221+ }{
222+ {
223+ name: "Exact domain match",
224+ query: "github",
225+ expectMatches: 1,
226+ expectFirst: "https://github.com",
227+ },
228+ {
229+ name: "Partial title match",
230+ query: "programming",
231+ expectMatches: 1,
232+ expectFirst: "https://golang.org",
233+ },
234+ {
235+ name: "Multiple matches",
236+ query: "o", // Should match github.com, stackoverflow.com, golang.org
237+ expectMatches: 3,
238+ },
239+ {
240+ name: "No matches",
241+ query: "nonexistent",
242+ expectMatches: 0,
243+ },
244+ }
245+
246+ for _, tt := range tests {
247+ t.Run(tt.name, func(t *testing.T) {
248+ matches := fuzzyMatcher.SearchHistory(tt.query, history)
249+
250+ if len(matches) < tt.expectMatches {
251+ t.Errorf("SearchHistory() returned %d matches, want >= %d", len(matches), tt.expectMatches)
252+ }
253+
254+ if len(matches) > 0 && tt.expectFirst != "" {
255+ if matches[0].HistoryEntry.Url != tt.expectFirst {
256+ t.Errorf("SearchHistory() first match = %q, want %q", matches[0].HistoryEntry.Url, tt.expectFirst)
257+ }
258+ }
259+
260+ // Verify scores are in descending order
261+ for i := 1; i < len(matches); i++ {
262+ if matches[i].Score > matches[i-1].Score {
263+ t.Errorf("SearchHistory() results not sorted by score: %f > %f", matches[i].Score, matches[i-1].Score)
264+ }
265+ }
266+
267+ // Verify all scores meet threshold
268+ for i, match := range matches {
269+ if match.Score < config.MinSimilarityThreshold {
270+ t.Errorf("SearchHistory() match %d score %f below threshold %f", i, match.Score, config.MinSimilarityThreshold)
271+ }
272+ }
273+ })
274+ }
275+}
276+
277+func TestFuzzyMatcher_CalculateRecencyScore(t *testing.T) {
278+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
279+ now := time.Now()
280+
281+ tests := []struct {
282+ name string
283+ lastVisit sql.NullTime
284+ expected float64
285+ delta float64
286+ }{
287+ {
288+ name: "Invalid time",
289+ lastVisit: sql.NullTime{Valid: false},
290+ expected: 0.0,
291+ delta: 0.0,
292+ },
293+ {
294+ name: "Recent visit (1 hour ago)",
295+ lastVisit: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
296+ expected: 0.95,
297+ delta: 0.1,
298+ },
299+ {
300+ name: "Old visit (30 days ago)",
301+ lastVisit: sql.NullTime{Time: now.Add(-30 * 24 * time.Hour), Valid: true},
302+ expected: 0.37,
303+ delta: 0.1,
304+ },
305+ {
306+ name: "Very old visit (90 days ago)",
307+ lastVisit: sql.NullTime{Time: now.Add(-90 * 24 * time.Hour), Valid: true},
308+ expected: 0.05,
309+ delta: 0.05,
310+ },
311+ }
312+
313+ for _, tt := range tests {
314+ t.Run(tt.name, func(t *testing.T) {
315+ result := fuzzyMatcher.calculateRecencyScore(tt.lastVisit)
316+
317+ if result < 0.0 || result > 1.0 {
318+ t.Errorf("calculateRecencyScore() = %v, should be between 0.0 and 1.0", result)
319+ }
320+
321+ diff := result - tt.expected
322+ if diff < 0 {
323+ diff = -diff
324+ }
325+ if diff > tt.delta {
326+ t.Errorf("calculateRecencyScore() = %v, want %v ±%v", result, tt.expected, tt.delta)
327+ }
328+ })
329+ }
330+}
331+
332+func TestFuzzyMatcher_CalculateVisitScore(t *testing.T) {
333+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
334+
335+ tests := []struct {
336+ name string
337+ visitCount sql.NullInt64
338+ expected float64
339+ delta float64
340+ }{
341+ {
342+ name: "Invalid count",
343+ visitCount: sql.NullInt64{Valid: false},
344+ expected: 0.0,
345+ delta: 0.0,
346+ },
347+ {
348+ name: "Zero visits",
349+ visitCount: sql.NullInt64{Int64: 0, Valid: true},
350+ expected: 0.0,
351+ delta: 0.0,
352+ },
353+ {
354+ name: "Single visit",
355+ visitCount: sql.NullInt64{Int64: 1, Valid: true},
356+ expected: 0.1,
357+ delta: 0.05,
358+ },
359+ {
360+ name: "Many visits",
361+ visitCount: sql.NullInt64{Int64: 100, Valid: true},
362+ expected: 0.67,
363+ delta: 0.1,
364+ },
365+ {
366+ name: "Very many visits",
367+ visitCount: sql.NullInt64{Int64: 1000, Valid: true},
368+ expected: 1.0,
369+ delta: 0.01,
370+ },
371+ }
372+
373+ for _, tt := range tests {
374+ t.Run(tt.name, func(t *testing.T) {
375+ result := fuzzyMatcher.calculateVisitScore(tt.visitCount)
376+
377+ if result < 0.0 || result > 1.0 {
378+ t.Errorf("calculateVisitScore() = %v, should be between 0.0 and 1.0", result)
379+ }
380+
381+ diff := result - tt.expected
382+ if diff < 0 {
383+ diff = -diff
384+ }
385+ if diff > tt.delta {
386+ t.Errorf("calculateVisitScore() = %v, want %v ±%v", result, tt.expected, tt.delta)
387+ }
388+ })
389+ }
390+}
391+
392+func TestFuzzyMatcher_IsDomainMatch(t *testing.T) {
393+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
394+
395+ tests := []struct {
396+ query string
397+ url string
398+ expected bool
399+ }{
400+ {"github", "https://github.com", true},
401+ {"github", "https://www.github.com", true},
402+ {"github", "https://api.github.com", true},
403+ {"github", "https://stackoverflow.com", false},
404+ {"stackoverflow", "https://stackoverflow.com/questions", true},
405+ {"google", "https://www.google.com/search", true},
406+ {"localhost", "http://localhost:8080", true},
407+ {"example", "https://example.org", true}, // Matches domain part
408+ {"sub.example", "https://sub.example.com", true},
409+ }
410+
411+ for _, tt := range tests {
412+ t.Run(tt.query+"_vs_"+tt.url, func(t *testing.T) {
413+ result := fuzzyMatcher.isDomainMatch(tt.query, tt.url)
414+ if result != tt.expected {
415+ t.Errorf("isDomainMatch(%q, %q) = %v, want %v", tt.query, tt.url, result, tt.expected)
416+ }
417+ })
418+ }
419+}
420+
421+func TestFuzzyMatcher_RankMatches(t *testing.T) {
422+ config := DefaultFuzzyConfig()
423+ fuzzyMatcher := NewFuzzyMatcher(config)
424+
425+ now := time.Now()
426+ matches := []FuzzyMatch{
427+ {
428+ HistoryEntry: &db.History{
429+ Url: "https://example.com/very/long/path/that/makes/url/longer",
430+ VisitCount: sql.NullInt64{Int64: 5, Valid: true},
431+ LastVisited: sql.NullTime{Time: now.Add(-10 * time.Hour), Valid: true},
432+ },
433+ Score: 0.5,
434+ },
435+ {
436+ HistoryEntry: &db.History{
437+ Url: "https://github.com",
438+ VisitCount: sql.NullInt64{Int64: 25, Valid: true},
439+ LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
440+ },
441+ Score: 0.7,
442+ },
443+ {
444+ HistoryEntry: &db.History{
445+ Url: "https://test.com",
446+ VisitCount: sql.NullInt64{Int64: 1, Valid: true},
447+ LastVisited: sql.NullTime{Time: now.Add(-24 * time.Hour), Valid: true},
448+ },
449+ Score: 0.6,
450+ },
451+ }
452+
453+ rankedMatches := fuzzyMatcher.RankMatches(matches, "github")
454+
455+ // Verify the matches are still sorted by score (descending)
456+ for i := 1; i < len(rankedMatches); i++ {
457+ if rankedMatches[i].Score > rankedMatches[i-1].Score {
458+ t.Errorf("RankMatches() results not sorted: %f > %f", rankedMatches[i].Score, rankedMatches[i-1].Score)
459+ }
460+ }
461+
462+ // The github.com match should get a boost and likely be first
463+ githubFound := false
464+ for i, match := range rankedMatches {
465+ if match.HistoryEntry.Url == "https://github.com" {
466+ githubFound = true
467+ // Should be boosted due to domain match
468+ if i > 0 && rankedMatches[0].HistoryEntry.Url != "https://github.com" {
469+ // It's possible another match scored higher even after boost
470+ t.Logf("GitHub match at position %d with score %f", i, match.Score)
471+ }
472+ break
473+ }
474+ }
475+
476+ if !githubFound {
477+ t.Errorf("RankMatches() didn't preserve GitHub match")
478+ }
479+}
480+
481+func BenchmarkFuzzyMatcher_JaroWinklerSimilarity(b *testing.B) {
482+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
483+
484+ b.ResetTimer()
485+ for i := 0; i < b.N; i++ {
486+ fuzzyMatcher.jaroWinklerSimilarity("github", "gihub")
487+ }
488+}
489+
490+func BenchmarkFuzzyMatcher_LevenshteinDistance(b *testing.B) {
491+ fuzzyMatcher := NewFuzzyMatcher(DefaultFuzzyConfig())
492+
493+ b.ResetTimer()
494+ for i := 0; i < b.N; i++ {
495+ fuzzyMatcher.LevenshteinDistance("programming", "programing")
496+ }
497+}
498+
499+func BenchmarkFuzzyMatcher_SearchHistory(b *testing.B) {
500+ config := DefaultFuzzyConfig()
501+ fuzzyMatcher := NewFuzzyMatcher(config)
502+
503+ now := time.Now()
504+ // Create larger history set for more realistic benchmark
505+ history := make([]*db.History, 100)
506+ for i := 0; i < 100; i++ {
507+ history[i] = &db.History{
508+ ID: int64(i + 1),
509+ Url: "https://example" + string(rune(i%26+'a')) + ".com",
510+ Title: sql.NullString{String: "Test Site " + string(rune(i%26+'A')), Valid: true},
511+ VisitCount: sql.NullInt64{Int64: int64(i % 50), Valid: true},
512+ LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true},
513+ }
514+ }
515+
516+ b.ResetTimer()
517+ for i := 0; i < b.N; i++ {
518+ fuzzyMatcher.SearchHistory("example", history)
519+ }
520+}
A internal/parser/history.go +407 -0
@@ -0,0 +1,407 @@
1+package parser
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/db"
9+)
10+
11+// HistorySearcher provides history search integration with fuzzy matching.
12+type HistorySearcher struct {
13+ provider HistoryProvider
14+ fuzzyMatcher *FuzzyMatcher
15+ fuzzyConfig *FuzzyConfig
16+}
17+
18+// NewHistorySearcher creates a new HistorySearcher.
19+func NewHistorySearcher(provider HistoryProvider, fuzzyConfig *FuzzyConfig) *HistorySearcher {
20+ if fuzzyConfig == nil {
21+ fuzzyConfig = DefaultFuzzyConfig()
22+ }
23+
24+ return &HistorySearcher{
25+ provider: provider,
26+ fuzzyMatcher: NewFuzzyMatcher(fuzzyConfig),
27+ fuzzyConfig: fuzzyConfig,
28+ }
29+}
30+
31+// SearchWithFuzzy performs fuzzy search on history entries.
32+func (hs *HistorySearcher) SearchWithFuzzy(query string, options ...SearchOption) (*HistorySearchResult, error) {
33+ if query == "" {
34+ return &HistorySearchResult{}, nil
35+ }
36+
37+ // Apply search options
38+ searchConfig := &SearchConfig{
39+ Limit: hs.fuzzyConfig.MaxResults,
40+ MinSimilarityThreshold: hs.fuzzyConfig.MinSimilarityThreshold,
41+ IncludeExactMatches: true,
42+ IncludeFuzzyMatches: true,
43+ SortByRelevance: true,
44+ }
45+
46+ for _, opt := range options {
47+ opt(searchConfig)
48+ }
49+
50+ startTime := time.Now()
51+
52+ // Get all history for fuzzy search
53+ allHistory, err := hs.provider.GetAllHistory()
54+ if err != nil {
55+ return nil, fmt.Errorf("failed to retrieve history: %w", err)
56+ }
57+
58+ result := &HistorySearchResult{
59+ Query: query,
60+ TotalEntries: len(allHistory),
61+ SearchTime: 0, // Will be set below
62+ }
63+
64+ // Perform exact search first if requested
65+ if searchConfig.IncludeExactMatches {
66+ exactMatches, err := hs.provider.SearchHistory(query, searchConfig.Limit)
67+ if err == nil && len(exactMatches) > 0 {
68+ // Convert to FuzzyMatch format with perfect scores
69+ for _, entry := range exactMatches {
70+ match := FuzzyMatch{
71+ HistoryEntry: entry,
72+ Score: 1.0,
73+ URLScore: 1.0,
74+ TitleScore: 1.0,
75+ RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
76+ VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
77+ MatchedField: "exact",
78+ }
79+ result.ExactMatches = append(result.ExactMatches, match)
80+ }
81+ }
82+ }
83+
84+ // Perform fuzzy search if requested
85+ if searchConfig.IncludeFuzzyMatches {
86+ // Create temporary config with search options
87+ tempConfig := *hs.fuzzyConfig
88+ tempConfig.MinSimilarityThreshold = searchConfig.MinSimilarityThreshold
89+ tempConfig.MaxResults = searchConfig.Limit
90+
91+ tempMatcher := NewFuzzyMatcher(&tempConfig)
92+ fuzzyMatches := tempMatcher.SearchHistory(query, allHistory)
93+
94+ // Filter out matches that are already in exact matches
95+ exactURLs := make(map[string]bool)
96+ for _, exact := range result.ExactMatches {
97+ exactURLs[exact.HistoryEntry.Url] = true
98+ }
99+
100+ for _, match := range fuzzyMatches {
101+ if !exactURLs[match.HistoryEntry.Url] {
102+ result.FuzzyMatches = append(result.FuzzyMatches, match)
103+ }
104+ }
105+ }
106+
107+ // Combine and sort results if requested
108+ if searchConfig.SortByRelevance {
109+ result.RankedMatches = hs.combineAndRankMatches(result.ExactMatches, result.FuzzyMatches, query)
110+ }
111+
112+ // Limit final results
113+ if len(result.RankedMatches) > searchConfig.Limit {
114+ result.RankedMatches = result.RankedMatches[:searchConfig.Limit]
115+ }
116+
117+ result.MatchCount = len(result.RankedMatches)
118+ result.SearchTime = time.Since(startTime)
119+
120+ return result, nil
121+}
122+
123+// SearchByDomain searches history entries by domain with fuzzy matching.
124+func (hs *HistorySearcher) SearchByDomain(domain string, options ...SearchOption) (*HistorySearchResult, error) {
125+ // Get all history entries
126+ allHistory, err := hs.provider.GetAllHistory()
127+ if err != nil {
128+ return nil, fmt.Errorf("failed to retrieve history: %w", err)
129+ }
130+
131+ // Filter by domain and apply fuzzy matching
132+ domainEntries := make([]*db.History, 0)
133+ domain = strings.ToLower(domain)
134+
135+ for _, entry := range allHistory {
136+ entryDomain := hs.extractDomain(entry.Url)
137+ if strings.Contains(strings.ToLower(entryDomain), domain) {
138+ domainEntries = append(domainEntries, entry)
139+ }
140+ }
141+
142+ // If we have domain matches, rank them by fuzzy score
143+ matches := make([]FuzzyMatch, 0)
144+ for _, entry := range domainEntries {
145+ match := hs.fuzzyMatcher.matchHistoryEntry(domain, entry)
146+ matches = append(matches, match)
147+ }
148+
149+ // Sort by score
150+ matches = hs.fuzzyMatcher.RankMatches(matches, domain)
151+
152+ return &HistorySearchResult{
153+ Query: domain,
154+ TotalEntries: len(allHistory),
155+ MatchCount: len(matches),
156+ RankedMatches: matches,
157+ SearchTime: time.Since(time.Now()),
158+ }, nil
159+}
160+
161+// SearchByTimeRange searches history entries within a time range with fuzzy matching.
162+func (hs *HistorySearcher) SearchByTimeRange(query string, startTime, endTime time.Time) (*HistorySearchResult, error) {
163+ // Get all history entries
164+ allHistory, err := hs.provider.GetAllHistory()
165+ if err != nil {
166+ return nil, fmt.Errorf("failed to retrieve history: %w", err)
167+ }
168+
169+ // Filter by time range
170+ filteredHistory := make([]*db.History, 0)
171+ for _, entry := range allHistory {
172+ if entry.LastVisited.Valid {
173+ visitTime := entry.LastVisited.Time
174+ if visitTime.After(startTime) && visitTime.Before(endTime) {
175+ filteredHistory = append(filteredHistory, entry)
176+ }
177+ }
178+ }
179+
180+ // Perform fuzzy search on filtered entries
181+ matches := hs.fuzzyMatcher.SearchHistory(query, filteredHistory)
182+
183+ return &HistorySearchResult{
184+ Query: query,
185+ TotalEntries: len(filteredHistory),
186+ MatchCount: len(matches),
187+ RankedMatches: matches,
188+ SearchTime: time.Since(time.Now()),
189+ }, nil
190+}
191+
192+// GetTopVisited returns the most visited sites with optional fuzzy filtering.
193+func (hs *HistorySearcher) GetTopVisited(limit int, filterQuery string) ([]FuzzyMatch, error) {
194+ allHistory, err := hs.provider.GetAllHistory()
195+ if err != nil {
196+ return nil, fmt.Errorf("failed to retrieve history: %w", err)
197+ }
198+
199+ // Sort by visit count
200+ matches := make([]FuzzyMatch, 0)
201+ for _, entry := range allHistory {
202+ // Apply filter if provided
203+ if filterQuery != "" {
204+ match := hs.fuzzyMatcher.matchHistoryEntry(filterQuery, entry)
205+ if match.Score < hs.fuzzyConfig.MinSimilarityThreshold {
206+ continue
207+ }
208+ matches = append(matches, match)
209+ } else {
210+ // No filter, just create basic match with visit score
211+ match := FuzzyMatch{
212+ HistoryEntry: entry,
213+ Score: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
214+ VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
215+ RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
216+ MatchedField: "visit_count",
217+ }
218+ matches = append(matches, match)
219+ }
220+ }
221+
222+ // Sort by visit count (descending)
223+ for i := 0; i < len(matches); i++ {
224+ for j := i + 1; j < len(matches); j++ {
225+ iVisits := int64(0)
226+ jVisits := int64(0)
227+ if matches[i].HistoryEntry.VisitCount.Valid {
228+ iVisits = matches[i].HistoryEntry.VisitCount.Int64
229+ }
230+ if matches[j].HistoryEntry.VisitCount.Valid {
231+ jVisits = matches[j].HistoryEntry.VisitCount.Int64
232+ }
233+ if jVisits > iVisits {
234+ matches[i], matches[j] = matches[j], matches[i]
235+ }
236+ }
237+ }
238+
239+ // Limit results
240+ if len(matches) > limit {
241+ matches = matches[:limit]
242+ }
243+
244+ return matches, nil
245+}
246+
247+// GetRecentWithFuzzy returns recent entries with optional fuzzy filtering.
248+func (hs *HistorySearcher) GetRecentWithFuzzy(limit int, filterQuery string) ([]FuzzyMatch, error) {
249+ recentHistory, err := hs.provider.GetRecentHistory(limit * 2) // Get more to filter
250+ if err != nil {
251+ return nil, fmt.Errorf("failed to retrieve recent history: %w", err)
252+ }
253+
254+ if filterQuery == "" {
255+ // No filter, convert to FuzzyMatch format
256+ matches := make([]FuzzyMatch, 0, len(recentHistory))
257+ for _, entry := range recentHistory {
258+ match := FuzzyMatch{
259+ HistoryEntry: entry,
260+ Score: 1.0, // Recent entries get full score
261+ RecencyScore: hs.fuzzyMatcher.calculateRecencyScore(entry.LastVisited),
262+ VisitScore: hs.fuzzyMatcher.calculateVisitScore(entry.VisitCount),
263+ MatchedField: "recent",
264+ }
265+ matches = append(matches, match)
266+ }
267+
268+ if len(matches) > limit {
269+ matches = matches[:limit]
270+ }
271+
272+ return matches, nil
273+ }
274+
275+ // Apply fuzzy filter
276+ matches := hs.fuzzyMatcher.SearchHistory(filterQuery, recentHistory)
277+ if len(matches) > limit {
278+ matches = matches[:limit]
279+ }
280+
281+ return matches, nil
282+}
283+
284+// combineAndRankMatches combines exact and fuzzy matches with proper ranking.
285+func (hs *HistorySearcher) combineAndRankMatches(exactMatches, fuzzyMatches []FuzzyMatch, query string) []FuzzyMatch {
286+ combined := make([]FuzzyMatch, 0, len(exactMatches)+len(fuzzyMatches))
287+
288+ // Add exact matches first (they get priority)
289+ combined = append(combined, exactMatches...)
290+
291+ // Add fuzzy matches
292+ combined = append(combined, fuzzyMatches...)
293+
294+ // Re-rank all matches
295+ return hs.fuzzyMatcher.RankMatches(combined, query)
296+}
297+
298+// extractDomain extracts domain from URL for domain-based searches.
299+func (hs *HistorySearcher) extractDomain(url string) string {
300+ // Simple domain extraction
301+ domain := url
302+ if strings.HasPrefix(domain, "http://") {
303+ domain = domain[7:]
304+ } else if strings.HasPrefix(domain, "https://") {
305+ domain = domain[8:]
306+ }
307+
308+ // Remove path
309+ if idx := strings.Index(domain, "/"); idx != -1 {
310+ domain = domain[:idx]
311+ }
312+
313+ // Remove port
314+ if idx := strings.LastIndex(domain, ":"); idx != -1 {
315+ domain = domain[:idx]
316+ }
317+
318+ return domain
319+}
320+
321+// HistorySearchResult represents the result of a history search operation.
322+type HistorySearchResult struct {
323+ Query string `json:"query"`
324+ TotalEntries int `json:"total_entries"`
325+ MatchCount int `json:"match_count"`
326+ ExactMatches []FuzzyMatch `json:"exact_matches,omitempty"`
327+ FuzzyMatches []FuzzyMatch `json:"fuzzy_matches,omitempty"`
328+ RankedMatches []FuzzyMatch `json:"ranked_matches"`
329+ SearchTime time.Duration `json:"search_time"`
330+}
331+
332+// SearchConfig holds configuration for history search operations.
333+type SearchConfig struct {
334+ Limit int `json:"limit"`
335+ MinSimilarityThreshold float64 `json:"min_similarity_threshold"`
336+ IncludeExactMatches bool `json:"include_exact_matches"`
337+ IncludeFuzzyMatches bool `json:"include_fuzzy_matches"`
338+ SortByRelevance bool `json:"sort_by_relevance"`
339+}
340+
341+// SearchOption is a functional option for configuring search operations.
342+type SearchOption func(*SearchConfig)
343+
344+// WithLimit sets the maximum number of results to return.
345+func WithLimit(limit int) SearchOption {
346+ return func(config *SearchConfig) {
347+ config.Limit = limit
348+ }
349+}
350+
351+// WithSimilarityThreshold sets the minimum similarity threshold for matches.
352+func WithSimilarityThreshold(threshold float64) SearchOption {
353+ return func(config *SearchConfig) {
354+ config.MinSimilarityThreshold = threshold
355+ }
356+}
357+
358+// WithExactMatches enables/disables exact match inclusion.
359+func WithExactMatches(include bool) SearchOption {
360+ return func(config *SearchConfig) {
361+ config.IncludeExactMatches = include
362+ }
363+}
364+
365+// WithFuzzyMatches enables/disables fuzzy match inclusion.
366+func WithFuzzyMatches(include bool) SearchOption {
367+ return func(config *SearchConfig) {
368+ config.IncludeFuzzyMatches = include
369+ }
370+}
371+
372+// WithRelevanceSort enables/disables relevance-based sorting.
373+func WithRelevanceSort(enable bool) SearchOption {
374+ return func(config *SearchConfig) {
375+ config.SortByRelevance = enable
376+ }
377+}
378+
379+// GetBestMatch returns the single best match for a query.
380+func (hs *HistorySearcher) GetBestMatch(query string) (*FuzzyMatch, error) {
381+ result, err := hs.SearchWithFuzzy(query, WithLimit(1))
382+ if err != nil {
383+ return nil, err
384+ }
385+
386+ if len(result.RankedMatches) == 0 {
387+ return nil, nil
388+ }
389+
390+ return &result.RankedMatches[0], nil
391+}
392+
393+// GetMatchesByScore returns matches above a specific score threshold.
394+func (hs *HistorySearcher) GetMatchesByScore(query string, minScore float64) ([]FuzzyMatch, error) {
395+ result, err := hs.SearchWithFuzzy(query, WithSimilarityThreshold(minScore))
396+ if err != nil {
397+ return nil, err
398+ }
399+
400+ return result.RankedMatches, nil
401+}
402+
403+// UpdateFuzzyConfig updates the fuzzy matching configuration.
404+func (hs *HistorySearcher) UpdateFuzzyConfig(config *FuzzyConfig) {
405+ hs.fuzzyConfig = config
406+ hs.fuzzyMatcher = NewFuzzyMatcher(config)
407+}
A internal/parser/parser.go +355 -0
@@ -0,0 +1,355 @@
1+package parser
2+
3+import (
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.
16+func (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.
52+func (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.
66+func (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.
104+func (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.
144+func (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.
158+func (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.
174+func (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.
196+func (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.
224+func (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.
230+func (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.
236+func (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.
242+func (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.
252+func (p *Parser) GetSupportedShortcuts() map[string]config.SearchShortcut {
253+ return p.config.SearchShortcuts
254+}
255+
256+// UpdateFuzzyConfig updates the fuzzy matching configuration.
257+func (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.
267+func (p *Parser) GetFuzzyConfig() *FuzzyConfig {
268+ return p.fuzzyConfig
269+}
270+
271+// PreviewParse provides a preview of what ParseInput would return without side effects.
272+func (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.
279+func (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.
327+func (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+}
A internal/parser/parser_test.go +611 -0
@@ -0,0 +1,611 @@
1+package parser
2+
3+import (
4+ "database/sql"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/internal/db"
10+)
11+
12+// MockHistoryProvider implements HistoryProvider for testing.
13+type MockHistoryProvider struct {
14+ history []*db.History
15+}
16+
17+func (m *MockHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) {
18+ if limit >= len(m.history) {
19+ return m.history, nil
20+ }
21+ return m.history[:limit], nil
22+}
23+
24+func (m *MockHistoryProvider) GetAllHistory() ([]*db.History, error) {
25+ return m.history, nil
26+}
27+
28+func (m *MockHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) {
29+ // Simple mock search - just return entries that contain the query
30+ result := make([]*db.History, 0)
31+ for _, entry := range m.history {
32+ if len(result) >= limit {
33+ break
34+ }
35+ // Simple contains check
36+ result = append(result, entry)
37+ }
38+ return result, nil
39+}
40+
41+func (m *MockHistoryProvider) GetHistoryByURL(url string) (*db.History, error) {
42+ for _, entry := range m.history {
43+ if entry.Url == url {
44+ return entry, nil
45+ }
46+ }
47+ return nil, sql.ErrNoRows
48+}
49+
50+// createMockHistory creates mock history data for testing.
51+func createMockHistory() []*db.History {
52+ now := time.Now()
53+ return []*db.History{
54+ {
55+ ID: 1,
56+ Url: "https://github.com",
57+ Title: sql.NullString{String: "GitHub", Valid: true},
58+ VisitCount: sql.NullInt64{Int64: 25, Valid: true},
59+ LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
60+ CreatedAt: sql.NullTime{Time: now.Add(-30 * 24 * time.Hour), Valid: true},
61+ },
62+ {
63+ ID: 2,
64+ Url: "https://stackoverflow.com",
65+ Title: sql.NullString{String: "Stack Overflow", Valid: true},
66+ VisitCount: sql.NullInt64{Int64: 15, Valid: true},
67+ LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
68+ CreatedAt: sql.NullTime{Time: now.Add(-20 * 24 * time.Hour), Valid: true},
69+ },
70+ {
71+ ID: 3,
72+ Url: "https://reddit.com",
73+ Title: sql.NullString{String: "Reddit", Valid: true},
74+ VisitCount: sql.NullInt64{Int64: 50, Valid: true},
75+ LastVisited: sql.NullTime{Time: now.Add(-3 * time.Hour), Valid: true},
76+ CreatedAt: sql.NullTime{Time: now.Add(-10 * 24 * time.Hour), Valid: true},
77+ },
78+ {
79+ ID: 4,
80+ Url: "https://golang.org",
81+ Title: sql.NullString{String: "The Go Programming Language", Valid: true},
82+ VisitCount: sql.NullInt64{Int64: 8, Valid: true},
83+ LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
84+ CreatedAt: sql.NullTime{Time: now.Add(-5 * 24 * time.Hour), Valid: true},
85+ },
86+ }
87+}
88+
89+// createTestConfig creates a test configuration.
90+func createTestConfig() *config.Config {
91+ return &config.Config{
92+ SearchShortcuts: map[string]config.SearchShortcut{
93+ "g": {
94+ URL: "https://www.google.com/search?q={query}",
95+ Description: "Google Search",
96+ },
97+ "gh": {
98+ URL: "https://github.com/search?q={query}",
99+ Description: "GitHub Search",
100+ },
101+ "so": {
102+ URL: "https://stackoverflow.com/search?q={query}",
103+ Description: "Stack Overflow Search",
104+ },
105+ },
106+ }
107+}
108+
109+func TestParser_ParseInput_DirectURL(t *testing.T) {
110+ mockHistory := createMockHistory()
111+ provider := &MockHistoryProvider{history: mockHistory}
112+ config := createTestConfig()
113+ parser := NewParser(config, provider)
114+
115+ tests := []struct {
116+ name string
117+ input string
118+ expectType InputType
119+ expectURL string
120+ expectConf float64
121+ }{
122+ {
123+ name: "HTTPS URL",
124+ input: "https://example.com",
125+ expectType: InputTypeDirectURL,
126+ expectURL: "https://example.com",
127+ expectConf: 1.0,
128+ },
129+ {
130+ name: "HTTP URL",
131+ input: "http://example.com",
132+ expectType: InputTypeDirectURL,
133+ expectURL: "http://example.com",
134+ expectConf: 1.0,
135+ },
136+ {
137+ name: "Domain only",
138+ input: "example.com",
139+ expectType: InputTypeDirectURL,
140+ expectURL: "https://example.com",
141+ expectConf: 1.0,
142+ },
143+ {
144+ name: "Subdomain",
145+ input: "www.example.com",
146+ expectType: InputTypeDirectURL,
147+ expectURL: "https://www.example.com",
148+ expectConf: 1.0,
149+ },
150+ {
151+ name: "Domain with path",
152+ input: "example.com/path",
153+ expectType: InputTypeDirectURL,
154+ expectURL: "https://example.com/path",
155+ expectConf: 1.0,
156+ },
157+ }
158+
159+ for _, tt := range tests {
160+ t.Run(tt.name, func(t *testing.T) {
161+ result, err := parser.ParseInput(tt.input)
162+ if err != nil {
163+ t.Fatalf("ParseInput() error = %v", err)
164+ }
165+
166+ if result.Type != tt.expectType {
167+ t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
168+ }
169+
170+ if result.URL != tt.expectURL {
171+ t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL)
172+ }
173+
174+ if result.Confidence != tt.expectConf {
175+ t.Errorf("ParseInput() confidence = %v, want %v", result.Confidence, tt.expectConf)
176+ }
177+ })
178+ }
179+}
180+
181+func TestParser_ParseInput_SearchShortcut(t *testing.T) {
182+ mockHistory := createMockHistory()
183+ provider := &MockHistoryProvider{history: mockHistory}
184+ config := createTestConfig()
185+ parser := NewParser(config, provider)
186+
187+ tests := []struct {
188+ name string
189+ input string
190+ expectType InputType
191+ expectURL string
192+ expectKey string
193+ expectQuery string
194+ }{
195+ {
196+ name: "Google search",
197+ input: "g: golang tutorial",
198+ expectType: InputTypeSearchShortcut,
199+ expectURL: "https://www.google.com/search?q=golang tutorial",
200+ expectKey: "g",
201+ expectQuery: "golang tutorial",
202+ },
203+ {
204+ name: "GitHub search",
205+ input: "gh: cobra cli",
206+ expectType: InputTypeSearchShortcut,
207+ expectURL: "https://github.com/search?q=cobra cli",
208+ expectKey: "gh",
209+ expectQuery: "cobra cli",
210+ },
211+ {
212+ name: "Stack Overflow search",
213+ input: "so: how to fuzzy search",
214+ expectType: InputTypeSearchShortcut,
215+ expectURL: "https://stackoverflow.com/search?q=how to fuzzy search",
216+ expectKey: "so",
217+ expectQuery: "how to fuzzy search",
218+ },
219+ }
220+
221+ for _, tt := range tests {
222+ t.Run(tt.name, func(t *testing.T) {
223+ result, err := parser.ParseInput(tt.input)
224+ if err != nil {
225+ t.Fatalf("ParseInput() error = %v", err)
226+ }
227+
228+ if result.Type != tt.expectType {
229+ t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
230+ }
231+
232+ if result.URL != tt.expectURL {
233+ t.Errorf("ParseInput() URL = %v, want %v", result.URL, tt.expectURL)
234+ }
235+
236+ if result.Shortcut == nil {
237+ t.Fatalf("ParseInput() shortcut is nil")
238+ }
239+
240+ if result.Shortcut.Key != tt.expectKey {
241+ t.Errorf("ParseInput() shortcut key = %v, want %v", result.Shortcut.Key, tt.expectKey)
242+ }
243+
244+ if result.Shortcut.Query != tt.expectQuery {
245+ t.Errorf("ParseInput() shortcut query = %v, want %v", result.Shortcut.Query, tt.expectQuery)
246+ }
247+ })
248+ }
249+}
250+
251+func TestParser_ParseInput_HistorySearch(t *testing.T) {
252+ mockHistory := createMockHistory()
253+ provider := &MockHistoryProvider{history: mockHistory}
254+ config := createTestConfig()
255+ parser := NewParser(config, provider)
256+
257+ tests := []struct {
258+ name string
259+ input string
260+ expectType InputType
261+ expectMatches bool
262+ minConfidence float64
263+ }{
264+ {
265+ name: "Exact domain match",
266+ input: "github",
267+ expectType: InputTypeHistorySearch,
268+ expectMatches: true,
269+ minConfidence: 0.5,
270+ },
271+ {
272+ name: "Partial title match",
273+ input: "stack",
274+ expectType: InputTypeHistorySearch,
275+ expectMatches: true,
276+ minConfidence: 0.3,
277+ },
278+ {
279+ name: "Programming language search",
280+ input: "golang",
281+ expectType: InputTypeHistorySearch,
282+ expectMatches: true,
283+ minConfidence: 0.3,
284+ },
285+ }
286+
287+ for _, tt := range tests {
288+ t.Run(tt.name, func(t *testing.T) {
289+ result, err := parser.ParseInput(tt.input)
290+ if err != nil {
291+ t.Fatalf("ParseInput() error = %v", err)
292+ }
293+
294+ if result.Type != tt.expectType {
295+ t.Errorf("ParseInput() type = %v, want %v", result.Type, tt.expectType)
296+ }
297+
298+ if tt.expectMatches && len(result.FuzzyMatches) == 0 {
299+ t.Errorf("ParseInput() expected fuzzy matches but got none")
300+ }
301+
302+ if result.Confidence < tt.minConfidence {
303+ t.Errorf("ParseInput() confidence = %v, want >= %v", result.Confidence, tt.minConfidence)
304+ }
305+ })
306+ }
307+}
308+
309+func TestParser_FuzzySearchHistory(t *testing.T) {
310+ mockHistory := createMockHistory()
311+ provider := &MockHistoryProvider{history: mockHistory}
312+ config := createTestConfig()
313+ parser := NewParser(config, provider)
314+
315+ tests := []struct {
316+ name string
317+ query string
318+ threshold float64
319+ expectMatches int
320+ expectFirst string // Expected first match URL
321+ }{
322+ {
323+ name: "GitHub search",
324+ query: "github",
325+ threshold: 0.3,
326+ expectMatches: 1,
327+ expectFirst: "https://github.com",
328+ },
329+ {
330+ name: "Programming search",
331+ query: "programming",
332+ threshold: 0.2,
333+ expectMatches: 1,
334+ expectFirst: "https://golang.org",
335+ },
336+ {
337+ name: "Stack search",
338+ query: "stack",
339+ threshold: 0.3,
340+ expectMatches: 1,
341+ expectFirst: "https://stackoverflow.com",
342+ },
343+ }
344+
345+ for _, tt := range tests {
346+ t.Run(tt.name, func(t *testing.T) {
347+ matches, err := parser.FuzzySearchHistory(tt.query, tt.threshold)
348+ if err != nil {
349+ t.Fatalf("FuzzySearchHistory() error = %v", err)
350+ }
351+
352+ if len(matches) < tt.expectMatches {
353+ t.Errorf("FuzzySearchHistory() matches = %d, want >= %d", len(matches), tt.expectMatches)
354+ }
355+
356+ if len(matches) > 0 && tt.expectFirst != "" {
357+ if matches[0].HistoryEntry.Url != tt.expectFirst {
358+ t.Errorf("FuzzySearchHistory() first match = %v, want %v", matches[0].HistoryEntry.Url, tt.expectFirst)
359+ }
360+ }
361+ })
362+ }
363+}
364+
365+func TestParser_SuggestCompletions(t *testing.T) {
366+ mockHistory := createMockHistory()
367+ provider := &MockHistoryProvider{history: mockHistory}
368+ config := createTestConfig()
369+ parser := NewParser(config, provider)
370+
371+ tests := []struct {
372+ name string
373+ input string
374+ limit int
375+ expectCount int
376+ }{
377+ {
378+ name: "GitHub completion",
379+ input: "gith",
380+ limit: 5,
381+ expectCount: 1,
382+ },
383+ {
384+ name: "Shortcut completion",
385+ input: "g",
386+ limit: 5,
387+ expectCount: 2, // "g" and "gh" shortcuts
388+ },
389+ {
390+ name: "Empty input",
391+ input: "",
392+ limit: 5,
393+ expectCount: 0,
394+ },
395+ }
396+
397+ for _, tt := range tests {
398+ t.Run(tt.name, func(t *testing.T) {
399+ completions, err := parser.SuggestCompletions(tt.input, tt.limit)
400+ if err != nil {
401+ t.Fatalf("SuggestCompletions() error = %v", err)
402+ }
403+
404+ if len(completions) != tt.expectCount {
405+ t.Errorf("SuggestCompletions() count = %d, want %d", len(completions), tt.expectCount)
406+ }
407+ })
408+ }
409+}
410+
411+func TestParser_ProcessShortcut(t *testing.T) {
412+ config := createTestConfig()
413+ mockHistory := createMockHistory()
414+ provider := &MockHistoryProvider{history: mockHistory}
415+ parser := NewParser(config, provider)
416+
417+ tests := []struct {
418+ name string
419+ shortcut string
420+ query string
421+ expectURL string
422+ expectErr bool
423+ }{
424+ {
425+ name: "Google shortcut",
426+ shortcut: "g",
427+ query: "test query",
428+ expectURL: "https://www.google.com/search?q=test query",
429+ expectErr: false,
430+ },
431+ {
432+ name: "GitHub shortcut",
433+ shortcut: "gh",
434+ query: "golang",
435+ expectURL: "https://github.com/search?q=golang",
436+ expectErr: false,
437+ },
438+ {
439+ name: "Unknown shortcut",
440+ shortcut: "unknown",
441+ query: "test",
442+ expectURL: "",
443+ expectErr: true,
444+ },
445+ }
446+
447+ for _, tt := range tests {
448+ t.Run(tt.name, func(t *testing.T) {
449+ url, err := parser.ProcessShortcut(tt.shortcut, tt.query, config.SearchShortcuts)
450+
451+ if tt.expectErr {
452+ if err == nil {
453+ t.Errorf("ProcessShortcut() expected error but got none")
454+ }
455+ return
456+ }
457+
458+ if err != nil {
459+ t.Fatalf("ProcessShortcut() error = %v", err)
460+ }
461+
462+ if url != tt.expectURL {
463+ t.Errorf("ProcessShortcut() URL = %v, want %v", url, tt.expectURL)
464+ }
465+ })
466+ }
467+}
468+
469+func TestParser_ValidateConfig(t *testing.T) {
470+ mockHistory := createMockHistory()
471+ provider := &MockHistoryProvider{history: mockHistory}
472+
473+ tests := []struct {
474+ name string
475+ config *config.Config
476+ fuzzy *FuzzyConfig
477+ provider HistoryProvider
478+ expectErr bool
479+ }{
480+ {
481+ name: "Valid configuration",
482+ config: createTestConfig(),
483+ fuzzy: DefaultFuzzyConfig(),
484+ provider: provider,
485+ expectErr: false,
486+ },
487+ {
488+ name: "Nil config",
489+ config: nil,
490+ fuzzy: DefaultFuzzyConfig(),
491+ provider: provider,
492+ expectErr: true,
493+ },
494+ {
495+ name: "Nil fuzzy config",
496+ config: createTestConfig(),
497+ fuzzy: nil,
498+ provider: provider,
499+ expectErr: true,
500+ },
501+ {
502+ name: "Nil history provider",
503+ config: createTestConfig(),
504+ fuzzy: DefaultFuzzyConfig(),
505+ provider: nil,
506+ expectErr: true,
507+ },
508+ }
509+
510+ for _, tt := range tests {
511+ t.Run(tt.name, func(t *testing.T) {
512+ parser := NewParser(tt.config, tt.provider, WithFuzzyConfig(tt.fuzzy))
513+
514+ err := parser.ValidateConfig()
515+
516+ if tt.expectErr {
517+ if err == nil {
518+ t.Errorf("ValidateConfig() expected error but got none")
519+ }
520+ } else {
521+ if err != nil {
522+ t.Errorf("ValidateConfig() unexpected error = %v", err)
523+ }
524+ }
525+ })
526+ }
527+}
528+
529+func TestFuzzyConfig_IsValid(t *testing.T) {
530+ tests := []struct {
531+ name string
532+ config *FuzzyConfig
533+ expect bool
534+ }{
535+ {
536+ name: "Default config",
537+ config: DefaultFuzzyConfig(),
538+ expect: true,
539+ },
540+ {
541+ name: "Invalid threshold (negative)",
542+ config: &FuzzyConfig{
543+ MinSimilarityThreshold: -0.1,
544+ MaxResults: 10,
545+ URLWeight: 0.4,
546+ TitleWeight: 0.3,
547+ RecencyWeight: 0.2,
548+ VisitWeight: 0.1,
549+ RecencyDecayDays: 30,
550+ },
551+ expect: false,
552+ },
553+ {
554+ name: "Invalid threshold (>1.0)",
555+ config: &FuzzyConfig{
556+ MinSimilarityThreshold: 1.1,
557+ MaxResults: 10,
558+ URLWeight: 0.4,
559+ TitleWeight: 0.3,
560+ RecencyWeight: 0.2,
561+ VisitWeight: 0.1,
562+ RecencyDecayDays: 30,
563+ },
564+ expect: false,
565+ },
566+ {
567+ name: "Invalid MaxResults",
568+ config: &FuzzyConfig{
569+ MinSimilarityThreshold: 0.3,
570+ MaxResults: 0,
571+ URLWeight: 0.4,
572+ TitleWeight: 0.3,
573+ RecencyWeight: 0.2,
574+ VisitWeight: 0.1,
575+ RecencyDecayDays: 30,
576+ },
577+ expect: false,
578+ },
579+ }
580+
581+ for _, tt := range tests {
582+ t.Run(tt.name, func(t *testing.T) {
583+ result := tt.config.IsValid()
584+ if result != tt.expect {
585+ t.Errorf("FuzzyConfig.IsValid() = %v, want %v", result, tt.expect)
586+ }
587+ })
588+ }
589+}
590+
591+func TestInputType_String(t *testing.T) {
592+ tests := []struct {
593+ inputType InputType
594+ expected string
595+ }{
596+ {InputTypeDirectURL, "direct_url"},
597+ {InputTypeSearchShortcut, "search_shortcut"},
598+ {InputTypeHistorySearch, "history_search"},
599+ {InputTypeFallbackSearch, "fallback_search"},
600+ {InputType(99), "unknown"}, // Unknown type
601+ }
602+
603+ for _, tt := range tests {
604+ t.Run(tt.expected, func(t *testing.T) {
605+ result := tt.inputType.String()
606+ if result != tt.expected {
607+ t.Errorf("InputType.String() = %v, want %v", result, tt.expected)
608+ }
609+ })
610+ }
611+}
A internal/parser/types.go +171 -0
@@ -0,0 +1,171 @@
1+// Package parser provides URL parsing and fuzzy finding functionality for dumber.
2+package parser
3+
4+import (
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/db"
8+ "github.com/bnema/dumber/internal/config"
9+)
10+
11+// InputType represents the type of user input detected.
12+type InputType int
13+
14+const (
15+ // InputTypeDirectURL represents a direct URL input (e.g., "reddit.com", "https://example.com")
16+ InputTypeDirectURL InputType = iota
17+ // InputTypeSearchShortcut represents a search shortcut (e.g., "g: golang tutorial")
18+ InputTypeSearchShortcut
19+ // InputTypeHistorySearch represents a history search query
20+ InputTypeHistorySearch
21+ // InputTypeFallbackSearch represents a fallback web search
22+ InputTypeFallbackSearch
23+)
24+
25+// ParseResult represents the result of parsing user input.
26+type ParseResult struct {
27+ // Type indicates the detected input type
28+ Type InputType `json:"type"`
29+ // URL is the final URL to navigate to
30+ URL string `json:"url"`
31+ // Query is the original user query
32+ Query string `json:"query"`
33+ // Confidence is the confidence score of the parsing (0.0-1.0)
34+ Confidence float64 `json:"confidence"`
35+ // FuzzyMatches contains fuzzy search results if applicable
36+ FuzzyMatches []FuzzyMatch `json:"fuzzy_matches,omitempty"`
37+ // Shortcut contains the detected shortcut information
38+ Shortcut *DetectedShortcut `json:"shortcut,omitempty"`
39+ // ProcessingTime is the time taken to process the input
40+ ProcessingTime time.Duration `json:"processing_time"`
41+}
42+
43+// DetectedShortcut represents a detected search shortcut.
44+type DetectedShortcut struct {
45+ // Key is the shortcut key (e.g., "g", "gh")
46+ Key string `json:"key"`
47+ // Query is the search query part
48+ Query string `json:"query"`
49+ // URL is the constructed URL
50+ URL string `json:"url"`
51+ // Description is the shortcut description
52+ Description string `json:"description"`
53+}
54+
55+// FuzzyMatch represents a fuzzy search match from history.
56+type FuzzyMatch struct {
57+ // HistoryEntry is the matched history entry
58+ HistoryEntry *db.History `json:"history_entry"`
59+ // Score is the similarity score (0.0-1.0, higher is better)
60+ Score float64 `json:"score"`
61+ // URLScore is the URL similarity score
62+ URLScore float64 `json:"url_score"`
63+ // TitleScore is the title similarity score
64+ TitleScore float64 `json:"title_score"`
65+ // RecencyScore is the recency score based on last visit
66+ RecencyScore float64 `json:"recency_score"`
67+ // VisitScore is the score based on visit count
68+ VisitScore float64 `json:"visit_score"`
69+ // MatchedField indicates which field matched (url, title, or both)
70+ MatchedField string `json:"matched_field"`
71+}
72+
73+// HistoryProvider defines the interface for history data access.
74+type HistoryProvider interface {
75+ // GetRecentHistory returns recent history entries limited by count
76+ GetRecentHistory(limit int) ([]*db.History, error)
77+ // GetAllHistory returns all history entries for fuzzy search
78+ GetAllHistory() ([]*db.History, error)
79+ // SearchHistory performs a basic text search in history
80+ SearchHistory(query string, limit int) ([]*db.History, error)
81+ // GetHistoryByURL retrieves history entry by exact URL match
82+ GetHistoryByURL(url string) (*db.History, error)
83+}
84+
85+// FuzzyConfig holds configuration for fuzzy matching algorithms.
86+type FuzzyConfig struct {
87+ // MinSimilarityThreshold is the minimum similarity score to consider a match (0.0-1.0)
88+ MinSimilarityThreshold float64 `json:"min_similarity_threshold"`
89+ // MaxResults is the maximum number of fuzzy results to return
90+ MaxResults int `json:"max_results"`
91+ // URLWeight is the weight for URL similarity in scoring
92+ URLWeight float64 `json:"url_weight"`
93+ // TitleWeight is the weight for title similarity in scoring
94+ TitleWeight float64 `json:"title_weight"`
95+ // RecencyWeight is the weight for recency in scoring
96+ RecencyWeight float64 `json:"recency_weight"`
97+ // VisitWeight is the weight for visit count in scoring
98+ VisitWeight float64 `json:"visit_weight"`
99+ // RecencyDecayDays is the number of days for recency score decay
100+ RecencyDecayDays int `json:"recency_decay_days"`
101+}
102+
103+// Parser handles URL parsing and fuzzy finding operations.
104+type Parser struct {
105+ config *config.Config
106+ fuzzyConfig *FuzzyConfig
107+ historyProvider HistoryProvider
108+}
109+
110+// ParserOption is a functional option for configuring the Parser.
111+type ParserOption func(*Parser)
112+
113+// NewParser creates a new Parser instance with the given configuration.
114+func NewParser(cfg *config.Config, historyProvider HistoryProvider, opts ...ParserOption) *Parser {
115+ p := &Parser{
116+ config: cfg,
117+ historyProvider: historyProvider,
118+ fuzzyConfig: DefaultFuzzyConfig(),
119+ }
120+
121+ for _, opt := range opts {
122+ opt(p)
123+ }
124+
125+ return p
126+}
127+
128+// WithFuzzyConfig sets custom fuzzy matching configuration.
129+func WithFuzzyConfig(fuzzyConfig *FuzzyConfig) ParserOption {
130+ return func(p *Parser) {
131+ p.fuzzyConfig = fuzzyConfig
132+ }
133+}
134+
135+// DefaultFuzzyConfig returns default fuzzy matching configuration.
136+func DefaultFuzzyConfig() *FuzzyConfig {
137+ return &FuzzyConfig{
138+ MinSimilarityThreshold: 0.3,
139+ MaxResults: 10,
140+ URLWeight: 0.4,
141+ TitleWeight: 0.3,
142+ RecencyWeight: 0.2,
143+ VisitWeight: 0.1,
144+ RecencyDecayDays: 30,
145+ }
146+}
147+
148+// String returns a string representation of InputType.
149+func (t InputType) String() string {
150+ switch t {
151+ case InputTypeDirectURL:
152+ return "direct_url"
153+ case InputTypeSearchShortcut:
154+ return "search_shortcut"
155+ case InputTypeHistorySearch:
156+ return "history_search"
157+ case InputTypeFallbackSearch:
158+ return "fallback_search"
159+ default:
160+ return "unknown"
161+ }
162+}
163+
164+// IsValid returns true if the FuzzyConfig has valid values.
165+func (fc *FuzzyConfig) IsValid() bool {
166+ return fc.MinSimilarityThreshold >= 0.0 && fc.MinSimilarityThreshold <= 1.0 &&
167+ fc.MaxResults > 0 &&
168+ fc.URLWeight >= 0.0 && fc.TitleWeight >= 0.0 &&
169+ fc.RecencyWeight >= 0.0 && fc.VisitWeight >= 0.0 &&
170+ fc.RecencyDecayDays > 0
171+}
A internal/parser/validation.go +407 -0
@@ -0,0 +1,407 @@
1+package parser
2+
3+import (
4+ "net"
5+ "net/url"
6+ "regexp"
7+ "strings"
8+)
9+
10+var (
11+ // domainRegex matches valid domain names
12+ domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`)
13+
14+ // ipRegex matches IPv4 addresses
15+ ipRegex = regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}$`)
16+
17+ // urlSchemeRegex matches URL schemes
18+ urlSchemeRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*://`)
19+
20+ // shortcutRegex matches search shortcuts (e.g., "g:", "gh:")
21+ shortcutRegex = regexp.MustCompile(`^([a-zA-Z0-9]+):\s*(.*)$`)
22+
23+ // Common top-level domains for validation
24+ commonTLDs = map[string]bool{
25+ "com": true, "org": true, "net": true, "edu": true, "gov": true, "mil": true,
26+ "int": true, "co": true, "io": true, "ai": true, "ly": true, "me": true,
27+ "tv": true, "cc": true, "to": true, "app": true, "dev": true, "tech": true,
28+ "info": true, "biz": true, "name": true, "museum": true, "travel": true,
29+ "xyz": true, "fr": true, "de": true, "uk": true, "ca": true, "us": true,
30+ "au": true, "jp": true, "cn": true, "in": true, "ru": true, "br": true,
31+ }
32+)
33+
34+// URLValidator provides URL validation utilities.
35+type URLValidator struct{}
36+
37+// NewURLValidator creates a new URLValidator.
38+func NewURLValidator() *URLValidator {
39+ return &URLValidator{}
40+}
41+
42+// IsValidURL checks if the input represents a valid URL.
43+func (v *URLValidator) IsValidURL(input string) bool {
44+ if input == "" {
45+ return false
46+ }
47+
48+ // Check if it already has a scheme
49+ if urlSchemeRegex.MatchString(input) {
50+ return v.isWellFormedURL(input)
51+ }
52+
53+ // Check if it looks like a domain or IP
54+ return v.isDomain(input) || v.isIPAddress(input)
55+}
56+
57+// IsDirectURL checks if the input should be treated as a direct URL.
58+func (v *URLValidator) IsDirectURL(input string) bool {
59+ if input == "" {
60+ return false
61+ }
62+
63+ input = strings.TrimSpace(input)
64+
65+ // Has explicit scheme
66+ if urlSchemeRegex.MatchString(input) {
67+ return v.isWellFormedURL(input)
68+ }
69+
70+ // Looks like a domain
71+ if v.isDomain(input) {
72+ return true
73+ }
74+
75+ // Looks like an IP address
76+ if v.isIPAddress(input) {
77+ return true
78+ }
79+
80+ // Contains domain-like patterns
81+ if v.looksLikeDomain(input) {
82+ return true
83+ }
84+
85+ return false
86+}
87+
88+// IsSearchShortcut checks if the input matches a search shortcut pattern.
89+func (v *URLValidator) IsSearchShortcut(input string) (bool, string, string) {
90+ input = strings.TrimSpace(input)
91+
92+ // Don't match URLs with schemes (like https://example.com)
93+ if urlSchemeRegex.MatchString(input) {
94+ return false, "", ""
95+ }
96+
97+ matches := shortcutRegex.FindStringSubmatch(input)
98+ if len(matches) != 3 {
99+ return false, "", ""
100+ }
101+
102+ shortcutKey := strings.ToLower(matches[1])
103+ query := strings.TrimSpace(matches[2])
104+
105+ return true, shortcutKey, query
106+}
107+
108+// NormalizeURL normalizes a URL by adding scheme if missing.
109+func (v *URLValidator) NormalizeURL(input string) string {
110+ input = strings.TrimSpace(input)
111+
112+ if input == "" {
113+ return input
114+ }
115+
116+ // Already has a scheme
117+ if urlSchemeRegex.MatchString(input) {
118+ return input
119+ }
120+
121+ // Add https:// for domains and IPs
122+ if v.isDomain(input) || v.isIPAddress(input) || v.looksLikeDomain(input) {
123+ return "https://" + input
124+ }
125+
126+ return input
127+}
128+
129+// ExtractDomain extracts the domain from a URL.
130+func (v *URLValidator) ExtractDomain(rawURL string) string {
131+ // Parse the URL
132+ parsedURL, err := url.Parse(v.NormalizeURL(rawURL))
133+ if err != nil {
134+ // Fallback: try to extract domain manually
135+ return v.extractDomainManual(rawURL)
136+ }
137+
138+ // Extract hostname (without port) from Host
139+ host := parsedURL.Host
140+ if host == "" {
141+ return v.extractDomainManual(rawURL)
142+ }
143+
144+ // Remove port from host if present
145+ hostname := host
146+ if idx := strings.LastIndex(host, ":"); idx != -1 {
147+ // Check if this looks like a port (numeric)
148+ portPart := host[idx+1:]
149+ if isNumeric(portPart) {
150+ hostname = host[:idx]
151+ }
152+ }
153+
154+ // Handle IPv6 addresses in brackets
155+ if strings.HasPrefix(hostname, "[") && strings.HasSuffix(hostname, "]") {
156+ hostname = hostname[1 : len(hostname)-1]
157+ }
158+
159+ return hostname
160+}
161+
162+// extractDomainManual manually extracts domain when URL parsing fails.
163+func (v *URLValidator) extractDomainManual(rawURL string) string {
164+ // Handle empty or invalid input
165+ if rawURL == "" {
166+ return ""
167+ }
168+
169+ // Remove protocol
170+ domain := rawURL
171+ if strings.HasPrefix(domain, "http://") {
172+ domain = domain[7:]
173+ } else if strings.HasPrefix(domain, "https://") {
174+ domain = domain[8:]
175+ }
176+
177+ // Handle IPv6 addresses in brackets
178+ if strings.HasPrefix(domain, "[") {
179+ if idx := strings.Index(domain, "]"); idx != -1 {
180+ return domain[1:idx] // Return IPv6 address without brackets
181+ }
182+ }
183+
184+ // Remove path
185+ if idx := strings.Index(domain, "/"); idx != -1 {
186+ domain = domain[:idx]
187+ }
188+
189+ // Remove query and fragment
190+ if idx := strings.Index(domain, "?"); idx != -1 {
191+ domain = domain[:idx]
192+ }
193+ if idx := strings.Index(domain, "#"); idx != -1 {
194+ domain = domain[:idx]
195+ }
196+
197+ // Remove port - be careful with IPv6 addresses
198+ if !strings.Contains(domain, "::") { // Not IPv6
199+ if idx := strings.LastIndex(domain, ":"); idx != -1 {
200+ // Check if this is actually a port (numeric)
201+ portPart := domain[idx+1:]
202+ if isNumeric(portPart) {
203+ domain = domain[:idx]
204+ }
205+ }
206+ }
207+
208+ return domain
209+}
210+
211+// isDomain checks if the input is a valid domain name.
212+func (v *URLValidator) isDomain(input string) bool {
213+ if !domainRegex.MatchString(input) {
214+ return false
215+ }
216+
217+ // Check for at least one dot (to distinguish from single words)
218+ if !strings.Contains(input, ".") {
219+ return false
220+ }
221+
222+ // Validate TLD
223+ parts := strings.Split(input, ".")
224+ if len(parts) < 2 {
225+ return false
226+ }
227+
228+ tld := strings.ToLower(parts[len(parts)-1])
229+
230+ // Check against common TLDs or validate length/format
231+ return commonTLDs[tld] || (len(tld) >= 2 && len(tld) <= 6)
232+}
233+
234+// isIPAddress checks if the input is a valid IP address.
235+func (v *URLValidator) isIPAddress(input string) bool {
236+ // Try parsing as IP
237+ ip := net.ParseIP(input)
238+ return ip != nil
239+}
240+
241+// looksLikeDomain checks if the input looks like a domain (more lenient).
242+func (v *URLValidator) looksLikeDomain(input string) bool {
243+ // Extract just the domain part if there's a path
244+ domainPart := input
245+ if slashIndex := strings.Index(input, "/"); slashIndex != -1 {
246+ domainPart = input[:slashIndex]
247+ }
248+
249+ // Must contain at least one dot
250+ if !strings.Contains(domainPart, ".") {
251+ return false
252+ }
253+
254+ // Must not contain spaces
255+ if strings.Contains(domainPart, " ") {
256+ return false
257+ }
258+
259+ // Must not be too long
260+ if len(domainPart) > 253 {
261+ return false
262+ }
263+
264+ // Check basic domain-like structure
265+ parts := strings.Split(domainPart, ".")
266+ if len(parts) < 2 {
267+ return false
268+ }
269+
270+ // Each part should be reasonable
271+ for _, part := range parts {
272+ if len(part) == 0 || len(part) > 63 {
273+ return false
274+ }
275+ // Should start and end with alphanumeric
276+ if len(part) > 0 {
277+ if !isAlphaNumeric(part[0]) || !isAlphaNumeric(part[len(part)-1]) {
278+ return false
279+ }
280+ }
281+ }
282+
283+ return true
284+}
285+
286+// isWellFormedURL checks if a URL with scheme is well-formed.
287+func (v *URLValidator) isWellFormedURL(rawURL string) bool {
288+ parsedURL, err := url.Parse(rawURL)
289+ if err != nil {
290+ return false
291+ }
292+
293+ // Must have a scheme and host
294+ if parsedURL.Scheme == "" || parsedURL.Host == "" {
295+ return false
296+ }
297+
298+ // Validate scheme
299+ validSchemes := map[string]bool{
300+ "http": true, "https": true, "ftp": true, "ftps": true,
301+ "file": true, "mailto": true, "tel": true, "sms": true,
302+ }
303+
304+ return validSchemes[strings.ToLower(parsedURL.Scheme)]
305+}
306+
307+// HasFileExtension checks if the URL path has a file extension.
308+func (v *URLValidator) HasFileExtension(rawURL string) bool {
309+ parsedURL, err := url.Parse(v.NormalizeURL(rawURL))
310+ if err != nil {
311+ return false
312+ }
313+
314+ path := parsedURL.Path
315+ if path == "" || path == "/" {
316+ return false
317+ }
318+
319+ // Check for file extension
320+ lastSlash := strings.LastIndex(path, "/")
321+ fileName := path
322+ if lastSlash != -1 && lastSlash < len(path)-1 {
323+ fileName = path[lastSlash+1:]
324+ }
325+
326+ return strings.Contains(fileName, ".") && !strings.HasSuffix(fileName, ".")
327+}
328+
329+// IsLocalhost checks if the URL is pointing to localhost.
330+func (v *URLValidator) IsLocalhost(rawURL string) bool {
331+ // Handle direct localhost strings (no protocol)
332+ rawURL = strings.ToLower(strings.TrimSpace(rawURL))
333+ if rawURL == "localhost" || rawURL == "127.0.0.1" || rawURL == "::1" {
334+ return true
335+ }
336+
337+ domain := v.ExtractDomain(rawURL)
338+ domain = strings.ToLower(domain)
339+
340+ // Remove port if present
341+ if idx := strings.LastIndex(domain, ":"); idx != -1 {
342+ portPart := domain[idx+1:]
343+ if isNumeric(portPart) {
344+ domain = domain[:idx]
345+ }
346+ }
347+
348+ return domain == "localhost" ||
349+ domain == "127.0.0.1" ||
350+ domain == "::1" ||
351+ strings.HasSuffix(domain, ".local")
352+}
353+
354+// SanitizeInput sanitizes user input for URL parsing.
355+func (v *URLValidator) SanitizeInput(input string) string {
356+ // Trim whitespace
357+ input = strings.TrimSpace(input)
358+
359+ // Remove control characters
360+ result := make([]rune, 0, len(input))
361+ for _, r := range input {
362+ if r >= 32 && r < 127 || r > 127 { // Keep printable ASCII and non-ASCII
363+ result = append(result, r)
364+ }
365+ }
366+
367+ return string(result)
368+}
369+
370+// Helper functions
371+
372+// isNumeric checks if a string contains only digits.
373+func isNumeric(s string) bool {
374+ if s == "" {
375+ return false
376+ }
377+ for _, r := range s {
378+ if r < '0' || r > '9' {
379+ return false
380+ }
381+ }
382+ return true
383+}
384+
385+// isAlphaNumeric checks if a character is alphanumeric.
386+func isAlphaNumeric(b byte) bool {
387+ return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
388+}
389+
390+// GetURLType determines the type of URL (direct, search, etc.).
391+func (v *URLValidator) GetURLType(input string) InputType {
392+ input = v.SanitizeInput(input)
393+
394+ // Check for direct URL first (this includes URLs with schemes)
395+ if v.IsDirectURL(input) {
396+ return InputTypeDirectURL
397+ }
398+
399+ // Check for search shortcut (but exclude URL schemes)
400+ if isShortcut, _, _ := v.IsSearchShortcut(input); isShortcut {
401+ return InputTypeSearchShortcut
402+ }
403+
404+ // Everything else is treated as search query initially
405+ // The parser will determine if it should be history search or web search
406+ return InputTypeHistorySearch
407+}
A internal/parser/validation_test.go +526 -0
@@ -0,0 +1,526 @@
1+package parser
2+
3+import (
4+ "testing"
5+)
6+
7+func TestURLValidator_IsValidURL(t *testing.T) {
8+ validator := NewURLValidator()
9+
10+ tests := []struct {
11+ name string
12+ input string
13+ expected bool
14+ }{
15+ // Valid URLs with scheme
16+ {"HTTPS URL", "https://example.com", true},
17+ {"HTTP URL", "http://example.com", true},
18+ {"URL with path", "https://example.com/path", true},
19+ {"URL with query", "https://example.com?q=test", true},
20+
21+ // Valid domains
22+ {"Simple domain", "example.com", true},
23+ {"Subdomain", "www.example.com", true},
24+ {"Multiple subdomains", "api.v1.example.com", true},
25+ {"Domain with path", "example.com/path", true},
26+
27+ // Valid IP addresses
28+ {"IPv4 address", "192.168.1.1", true},
29+ {"Localhost IP", "127.0.0.1", true},
30+
31+ // Invalid inputs
32+ {"Empty string", "", false},
33+ {"Just text", "hello world", false},
34+ {"Single word", "test", false},
35+ {"Space in domain", "exam ple.com", false},
36+ {"Invalid TLD", "example.toolongtobevalid", false},
37+ }
38+
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ result := validator.IsValidURL(tt.input)
42+ if result != tt.expected {
43+ t.Errorf("IsValidURL(%q) = %v, want %v", tt.input, result, tt.expected)
44+ }
45+ })
46+ }
47+}
48+
49+func TestURLValidator_IsDirectURL(t *testing.T) {
50+ validator := NewURLValidator()
51+
52+ tests := []struct {
53+ name string
54+ input string
55+ expected bool
56+ }{
57+ // Should be treated as direct URLs
58+ {"Complete HTTPS URL", "https://github.com", true},
59+ {"Complete HTTP URL", "http://example.com", true},
60+ {"Domain only", "github.com", true},
61+ {"Subdomain", "api.github.com", true},
62+ {"Domain with path", "github.com/user/repo", true},
63+ {"IP address", "192.168.1.1", true},
64+ {"Localhost", "127.0.0.1", true},
65+
66+ // Should NOT be treated as direct URLs
67+ {"Single word", "github", false},
68+ {"Search query", "golang tutorial", false},
69+ {"Shortcut format", "g: search query", false},
70+ {"Empty string", "", false},
71+ {"Just spaces", " ", false},
72+ }
73+
74+ for _, tt := range tests {
75+ t.Run(tt.name, func(t *testing.T) {
76+ result := validator.IsDirectURL(tt.input)
77+ if result != tt.expected {
78+ t.Errorf("IsDirectURL(%q) = %v, want %v", tt.input, result, tt.expected)
79+ }
80+ })
81+ }
82+}
83+
84+func TestURLValidator_IsSearchShortcut(t *testing.T) {
85+ validator := NewURLValidator()
86+
87+ tests := []struct {
88+ name string
89+ input string
90+ expectShortcut bool
91+ expectKey string
92+ expectQuery string
93+ }{
94+ {
95+ name: "Google shortcut",
96+ input: "g: golang tutorial",
97+ expectShortcut: true,
98+ expectKey: "g",
99+ expectQuery: "golang tutorial",
100+ },
101+ {
102+ name: "GitHub shortcut",
103+ input: "gh: cobra cli",
104+ expectShortcut: true,
105+ expectKey: "gh",
106+ expectQuery: "cobra cli",
107+ },
108+ {
109+ name: "Stack Overflow shortcut",
110+ input: "so: fuzzy search algorithm",
111+ expectShortcut: true,
112+ expectKey: "so",
113+ expectQuery: "fuzzy search algorithm",
114+ },
115+ {
116+ name: "Empty query",
117+ input: "g: ",
118+ expectShortcut: true,
119+ expectKey: "g",
120+ expectQuery: "",
121+ },
122+ {
123+ name: "Numeric shortcut",
124+ input: "123: test",
125+ expectShortcut: true,
126+ expectKey: "123",
127+ expectQuery: "test",
128+ },
129+ // Not shortcuts
130+ {
131+ name: "Just colon",
132+ input: ":",
133+ expectShortcut: false,
134+ },
135+ {
136+ name: "No colon",
137+ input: "github search",
138+ expectShortcut: false,
139+ },
140+ {
141+ name: "URL with colon",
142+ input: "https://example.com",
143+ expectShortcut: false,
144+ },
145+ {
146+ name: "Empty string",
147+ input: "",
148+ expectShortcut: false,
149+ },
150+ }
151+
152+ for _, tt := range tests {
153+ t.Run(tt.name, func(t *testing.T) {
154+ isShortcut, key, query := validator.IsSearchShortcut(tt.input)
155+
156+ if isShortcut != tt.expectShortcut {
157+ t.Errorf("IsSearchShortcut(%q) isShortcut = %v, want %v", tt.input, isShortcut, tt.expectShortcut)
158+ }
159+
160+ if tt.expectShortcut {
161+ if key != tt.expectKey {
162+ t.Errorf("IsSearchShortcut(%q) key = %q, want %q", tt.input, key, tt.expectKey)
163+ }
164+ if query != tt.expectQuery {
165+ t.Errorf("IsSearchShortcut(%q) query = %q, want %q", tt.input, query, tt.expectQuery)
166+ }
167+ }
168+ })
169+ }
170+}
171+
172+func TestURLValidator_NormalizeURL(t *testing.T) {
173+ validator := NewURLValidator()
174+
175+ tests := []struct {
176+ name string
177+ input string
178+ expected string
179+ }{
180+ // Already have scheme
181+ {"HTTPS URL", "https://example.com", "https://example.com"},
182+ {"HTTP URL", "http://example.com", "http://example.com"},
183+
184+ // Need scheme added
185+ {"Domain only", "example.com", "https://example.com"},
186+ {"Subdomain", "www.example.com", "https://www.example.com"},
187+ {"Domain with path", "example.com/path", "https://example.com/path"},
188+ {"IP address", "192.168.1.1", "https://192.168.1.1"},
189+
190+ // Special cases
191+ {"Empty string", "", ""},
192+ {"Just spaces", " ", ""},
193+ {"Non-URL text", "hello world", "hello world"}, // Should remain unchanged
194+ }
195+
196+ for _, tt := range tests {
197+ t.Run(tt.name, func(t *testing.T) {
198+ result := validator.NormalizeURL(tt.input)
199+ if result != tt.expected {
200+ t.Errorf("NormalizeURL(%q) = %q, want %q", tt.input, result, tt.expected)
201+ }
202+ })
203+ }
204+}
205+
206+func TestURLValidator_ExtractDomain(t *testing.T) {
207+ validator := NewURLValidator()
208+
209+ tests := []struct {
210+ name string
211+ input string
212+ expected string
213+ }{
214+ // URLs with scheme
215+ {"HTTPS URL", "https://github.com", "github.com"},
216+ {"HTTP URL", "http://example.com", "example.com"},
217+ {"URL with path", "https://github.com/user/repo", "github.com"},
218+ {"URL with query", "https://google.com/search?q=test", "google.com"},
219+ {"URL with port", "https://localhost:8080", "localhost"},
220+
221+ // Domains without scheme
222+ {"Domain only", "github.com", "github.com"},
223+ {"Subdomain", "api.github.com", "api.github.com"},
224+ {"Domain with path", "github.com/user/repo", "github.com"},
225+
226+ // IP addresses
227+ {"IPv4", "192.168.1.1", "192.168.1.1"},
228+ {"IPv4 with port", "192.168.1.1:8080", "192.168.1.1"},
229+
230+ // Edge cases
231+ {"Empty string", "", ""},
232+ {"Just path", "/path/to/resource", ""}, // Invalid input returns empty
233+ }
234+
235+ for _, tt := range tests {
236+ t.Run(tt.name, func(t *testing.T) {
237+ result := validator.ExtractDomain(tt.input)
238+ if result != tt.expected {
239+ t.Errorf("ExtractDomain(%q) = %q, want %q", tt.input, result, tt.expected)
240+ }
241+ })
242+ }
243+}
244+
245+func TestURLValidator_HasFileExtension(t *testing.T) {
246+ validator := NewURLValidator()
247+
248+ tests := []struct {
249+ name string
250+ input string
251+ expected bool
252+ }{
253+ // URLs with file extensions
254+ {"HTML file", "https://example.com/index.html", true},
255+ {"PDF file", "https://example.com/document.pdf", true},
256+ {"Image file", "https://example.com/image.jpg", true},
257+ {"File in subdirectory", "https://example.com/docs/readme.txt", true},
258+
259+ // URLs without file extensions
260+ {"Root URL", "https://example.com", false},
261+ {"Root with slash", "https://example.com/", false},
262+ {"Directory path", "https://example.com/docs/", false},
263+ {"Path without extension", "https://example.com/about", false},
264+
265+ // Edge cases
266+ {"Dot at end", "https://example.com/file.", false},
267+ {"Multiple dots", "https://example.com/file.name.ext", true},
268+ {"Query params", "https://example.com/file.html?q=test", true},
269+ }
270+
271+ for _, tt := range tests {
272+ t.Run(tt.name, func(t *testing.T) {
273+ result := validator.HasFileExtension(tt.input)
274+ if result != tt.expected {
275+ t.Errorf("HasFileExtension(%q) = %v, want %v", tt.input, result, tt.expected)
276+ }
277+ })
278+ }
279+}
280+
281+func TestURLValidator_IsLocalhost(t *testing.T) {
282+ validator := NewURLValidator()
283+
284+ tests := []struct {
285+ name string
286+ input string
287+ expected bool
288+ }{
289+ // Localhost variants
290+ {"localhost", "http://localhost", true},
291+ {"localhost with port", "http://localhost:3000", true},
292+ {"localhost HTTPS", "https://localhost:8080", true},
293+ {"127.0.0.1", "http://127.0.0.1", true},
294+ {"127.0.0.1 with port", "http://127.0.0.1:8080", true},
295+ {"IPv6 localhost", "http://[::1]", true},
296+ {"Local domain", "http://app.local", true},
297+
298+ // Non-localhost
299+ {"example.com", "https://example.com", false},
300+ {"github.com", "https://github.com", false},
301+ {"127.0.0.2", "http://127.0.0.2", false}, // Different local IP
302+ {"192.168.1.1", "http://192.168.1.1", false}, // Private network but not localhost
303+
304+ // Edge cases
305+ {"Empty string", "", false},
306+ {"Just localhost", "localhost", true},
307+ {"Just IP", "127.0.0.1", true},
308+ }
309+
310+ for _, tt := range tests {
311+ t.Run(tt.name, func(t *testing.T) {
312+ result := validator.IsLocalhost(tt.input)
313+ if result != tt.expected {
314+ t.Errorf("IsLocalhost(%q) = %v, want %v", tt.input, result, tt.expected)
315+ }
316+ })
317+ }
318+}
319+
320+func TestURLValidator_SanitizeInput(t *testing.T) {
321+ validator := NewURLValidator()
322+
323+ tests := []struct {
324+ name string
325+ input string
326+ expected string
327+ }{
328+ {"Normal text", "github.com", "github.com"},
329+ {"Text with spaces", " github.com ", "github.com"},
330+ {"Text with tabs", "\tgithub.com\t", "github.com"},
331+ {"Text with newlines", "\ngithub.com\n", "github.com"},
332+ {"Unicode text", "github.com/ñoño", "github.com/ñoño"},
333+ {"Empty string", "", ""},
334+ {"Only whitespace", " \t\n ", ""},
335+ {"Mixed whitespace and text", " \t github.com \n ", "github.com"},
336+ }
337+
338+ for _, tt := range tests {
339+ t.Run(tt.name, func(t *testing.T) {
340+ result := validator.SanitizeInput(tt.input)
341+ if result != tt.expected {
342+ t.Errorf("SanitizeInput(%q) = %q, want %q", tt.input, result, tt.expected)
343+ }
344+ })
345+ }
346+}
347+
348+func TestURLValidator_GetURLType(t *testing.T) {
349+ validator := NewURLValidator()
350+
351+ tests := []struct {
352+ name string
353+ input string
354+ expected InputType
355+ }{
356+ // Direct URLs
357+ {"HTTPS URL", "https://github.com", InputTypeDirectURL},
358+ {"Domain", "github.com", InputTypeDirectURL},
359+ {"IP address", "192.168.1.1", InputTypeDirectURL},
360+
361+ // Search shortcuts
362+ {"Google shortcut", "g: golang tutorial", InputTypeSearchShortcut},
363+ {"GitHub shortcut", "gh: cobra cli", InputTypeSearchShortcut},
364+
365+ // History search (everything else initially)
366+ {"Single word", "github", InputTypeHistorySearch},
367+ {"Search phrase", "golang tutorial", InputTypeHistorySearch},
368+ {"Question", "how to use fuzzy search", InputTypeHistorySearch},
369+ {"Empty string", "", InputTypeHistorySearch},
370+ }
371+
372+ for _, tt := range tests {
373+ t.Run(tt.name, func(t *testing.T) {
374+ result := validator.GetURLType(tt.input)
375+ if result != tt.expected {
376+ t.Errorf("GetURLType(%q) = %v, want %v", tt.input, result, tt.expected)
377+ }
378+ })
379+ }
380+}
381+
382+func TestURLValidator_IsDomain(t *testing.T) {
383+ validator := NewURLValidator()
384+
385+ tests := []struct {
386+ name string
387+ input string
388+ expected bool
389+ }{
390+ // Valid domains
391+ {"Simple domain", "example.com", true},
392+ {"Subdomain", "www.example.com", true},
393+ {"Multiple subdomains", "api.v1.example.com", true},
394+ {"Country TLD", "example.co.uk", true},
395+ {"New TLD", "example.dev", true},
396+
397+ // Invalid domains
398+ {"Single word", "example", false},
399+ {"No TLD", "www.example", false},
400+ {"Starts with dot", ".example.com", false},
401+ {"Ends with dot", "example.com.", false},
402+ {"Contains spaces", "exam ple.com", false},
403+ {"Too long segment", "verylongsubdomainthatexceedssixtyThreecharacterslimitfordomainsegments.com", false},
404+ {"Empty string", "", false},
405+ }
406+
407+ for _, tt := range tests {
408+ t.Run(tt.name, func(t *testing.T) {
409+ result := validator.isDomain(tt.input)
410+ if result != tt.expected {
411+ t.Errorf("isDomain(%q) = %v, want %v", tt.input, result, tt.expected)
412+ }
413+ })
414+ }
415+}
416+
417+func TestURLValidator_IsIPAddress(t *testing.T) {
418+ validator := NewURLValidator()
419+
420+ tests := []struct {
421+ name string
422+ input string
423+ expected bool
424+ }{
425+ // Valid IPv4
426+ {"Standard IPv4", "192.168.1.1", true},
427+ {"Localhost IPv4", "127.0.0.1", true},
428+ {"Zero IPv4", "0.0.0.0", true},
429+ {"Max IPv4", "255.255.255.255", true},
430+
431+ // Valid IPv6
432+ {"Standard IPv6", "2001:db8::1", true},
433+ {"Localhost IPv6", "::1", true},
434+
435+ // Invalid IPs
436+ {"Invalid IPv4 (out of range)", "256.256.256.256", false},
437+ {"Invalid IPv4 (too many octets)", "192.168.1.1.1", false},
438+ {"Invalid IPv4 (too few octets)", "192.168.1", false},
439+ {"Not an IP", "example.com", false},
440+ {"Empty string", "", false},
441+ }
442+
443+ for _, tt := range tests {
444+ t.Run(tt.name, func(t *testing.T) {
445+ result := validator.isIPAddress(tt.input)
446+ if result != tt.expected {
447+ t.Errorf("isIPAddress(%q) = %v, want %v", tt.input, result, tt.expected)
448+ }
449+ })
450+ }
451+}
452+
453+func TestURLValidator_LooksLikeDomain(t *testing.T) {
454+ validator := NewURLValidator()
455+
456+ tests := []struct {
457+ name string
458+ input string
459+ expected bool
460+ }{
461+ // Should look like domains
462+ {"Standard domain", "example.com", true},
463+ {"Subdomain", "www.example.com", true},
464+ {"Uncommon TLD", "example.xyz", true},
465+ {"Multiple levels", "a.b.c.example.com", true},
466+
467+ // Should not look like domains
468+ {"No dot", "example", false},
469+ {"Has space", "exam ple.com", false},
470+ {"Too long", string(make([]rune, 300)), false}, // Very long string
471+ {"Empty segment", "example..com", false},
472+ {"Starts with non-alphanumeric", "-example.com", false},
473+ {"Ends with non-alphanumeric", "example-.com", false},
474+ {"Empty string", "", false},
475+ }
476+
477+ for _, tt := range tests {
478+ t.Run(tt.name, func(t *testing.T) {
479+ result := validator.looksLikeDomain(tt.input)
480+ if result != tt.expected {
481+ t.Errorf("looksLikeDomain(%q) = %v, want %v", tt.input, result, tt.expected)
482+ }
483+ })
484+ }
485+}
486+
487+func BenchmarkURLValidator_IsValidURL(b *testing.B) {
488+ validator := NewURLValidator()
489+ testInputs := []string{
490+ "https://github.com",
491+ "github.com",
492+ "192.168.1.1",
493+ "g: search query",
494+ "just some text",
495+ }
496+
497+ b.ResetTimer()
498+ for i := 0; i < b.N; i++ {
499+ validator.IsValidURL(testInputs[i%len(testInputs)])
500+ }
501+}
502+
503+func BenchmarkURLValidator_IsDirectURL(b *testing.B) {
504+ validator := NewURLValidator()
505+ testInputs := []string{
506+ "https://github.com",
507+ "github.com",
508+ "search query",
509+ "g: shortcut",
510+ }
511+
512+ b.ResetTimer()
513+ for i := 0; i < b.N; i++ {
514+ validator.IsDirectURL(testInputs[i%len(testInputs)])
515+ }
516+}
517+
518+func BenchmarkURLValidator_ExtractDomain(b *testing.B) {
519+ validator := NewURLValidator()
520+ testURL := "https://api.github.com/users/octocat/repos"
521+
522+ b.ResetTimer()
523+ for i := 0; i < b.N; i++ {
524+ validator.ExtractDomain(testURL)
525+ }
526+}
M memory/constitution.md +1 -1
@@ -12,7 +12,7 @@
12 - Support any dmenu-compatible launcher (fuzzel, rofi, tofi, wofi, bemenu)12 - Support any dmenu-compatible launcher (fuzzel, rofi, tofi, wofi, bemenu)
13 - Standard stdin/stdout protocol - no launcher-specific integrations13 - Standard stdin/stdout protocol - no launcher-specific integrations
14 - Clean text-based format for history/suggestions14 - Clean text-based format for history/suggestions
15-- Pipeline-friendly: `dumb-browser --dmenu | launcher | dumb-browser --dmenu`15+- Pipeline-friendly: `dumber --dmenu | launcher | dumber --dmenu`
16 16
17 ### III. Smart URL Handling17 ### III. Smart URL Handling
18 - Direct URL detection: `reddit.com` → navigate directly18 - Direct URL detection: `reddit.com` → navigate directly
M migrations/001_initial.sql +1 -1
@@ -1,4 +1,4 @@
1--- Initial database schema for dumb-browser1+-- Initial database schema for dumber
2 -- History tracking for visited URLs2 -- History tracking for visited URLs
3 3
4 CREATE TABLE IF NOT EXISTS history (4 CREATE TABLE IF NOT EXISTS history (
M specs/001-init-project/contracts/cli-commands.md +36 -36
@@ -5,12 +5,12 @@
5 5
6 ## Root Command6 ## Root Command
7 7
8-### `dumb-browser init`8+### `dumber init`
9 **Purpose**: Initialize the project with all dependencies9 **Purpose**: Initialize the project with all dependencies
10 10
11 **Syntax**:11 **Syntax**:
12 ```bash12 ```bash
13-dumb-browser init [OPTIONS]13+dumber init [OPTIONS]
14 ```14 ```
15 15
16 **Options**:16 **Options**:
@@ -22,16 +22,16 @@ dumb-browser init [OPTIONS]
22 **Examples**:22 **Examples**:
23 ```bash23 ```bash
24 # Basic initialization24 # Basic initialization
25-dumb-browser init25+dumber init
26 26
27 # Preview mode27 # Preview mode
28-dumb-browser init --dry-run28+dumber init --dry-run
29 29
30 # Force reinitialize existing project30 # Force reinitialize existing project
31-dumb-browser init --force --verbose31+dumber init --force --verbose
32 32
33 # Specify Go version33 # Specify Go version
34-dumb-browser init --go-version=1.2134+dumber init --go-version=1.21
35 ```35 ```
36 36
37 **Exit Codes**:37 **Exit Codes**:
@@ -43,7 +43,7 @@ dumb-browser init --go-version=1.21
43 43
44 **Output Format**:44 **Output Format**:
45 ```45 ```
46-✓ Initializing Go module: dumb-browser46+✓ Initializing Go module: dumber
47 ✓ Installing CLI dependencies: cobra, viper47 ✓ Installing CLI dependencies: cobra, viper
48 ✓ Installing database dependencies: sqlite3, sqlc48 ✓ Installing database dependencies: sqlite3, sqlc
49 ✓ Installing validation: go-playground/validator49 ✓ Installing validation: go-playground/validator
@@ -57,18 +57,18 @@ Project initialized successfully!
57 57
58 Next steps:58 Next steps:
59 1. Review generated configs/59 1. Review generated configs/
60- 2. Run: go build ./cmd/dumb-browser60+ 2. Run: go build ./cmd/dumber
61- 3. Start development: dumb-browser --help61+ 3. Start development: dumber --help
62 ```62 ```
63 63
64 ## Subcommands64 ## Subcommands
65 65
66-### `dumb-browser init deps`66+### `dumber init deps`
67 **Purpose**: Install only dependencies without project structure67 **Purpose**: Install only dependencies without project structure
68 68
69 **Syntax**:69 **Syntax**:
70 ```bash70 ```bash
71-dumb-browser init deps [OPTIONS]71+dumber init deps [OPTIONS]
72 ```72 ```
73 73
74 **Options**:74 **Options**:
@@ -80,21 +80,21 @@ dumb-browser init deps [OPTIONS]
80 **Examples**:80 **Examples**:
81 ```bash81 ```bash
82 # Install only CLI dependencies82 # Install only CLI dependencies
83-dumb-browser init deps --only=cli83+dumber init deps --only=cli
84 84
85 # Install everything except Wails85 # Install everything except Wails
86-dumb-browser init deps --skip=wails86+dumber init deps --skip=wails
87 87
88 # Check current dependency status88 # Check current dependency status
89-dumb-browser init deps --check89+dumber init deps --check
90 ```90 ```
91 91
92-### `dumb-browser init structure`92+### `dumber init structure`
93 **Purpose**: Create project directory structure only93 **Purpose**: Create project directory structure only
94 94
95 **Syntax**:95 **Syntax**:
96 ```bash96 ```bash
97-dumb-browser init structure [OPTIONS]97+dumber init structure [OPTIONS]
98 ```98 ```
99 99
100 **Options**:100 **Options**:
@@ -104,21 +104,21 @@ dumb-browser init structure [OPTIONS]
104 **Examples**:104 **Examples**:
105 ```bash105 ```bash
106 # Create full project structure106 # Create full project structure
107-dumb-browser init structure107+dumber init structure
108 108
109 # Preview directory layout109 # Preview directory layout
110-dumb-browser init structure --preview110+dumber init structure --preview
111 111
112 # Minimal structure (no example files)112 # Minimal structure (no example files)
113-dumb-browser init structure --template=minimal113+dumber init structure --template=minimal
114 ```114 ```
115 115
116-### `dumb-browser init config`116+### `dumber init config`
117 **Purpose**: Generate configuration files only117 **Purpose**: Generate configuration files only
118 118
119 **Syntax**:119 **Syntax**:
120 ```bash120 ```bash
121-dumb-browser init config [OPTIONS]121+dumber init config [OPTIONS]
122 ```122 ```
123 123
124 **Options**:124 **Options**:
@@ -128,23 +128,23 @@ dumb-browser init config [OPTIONS]
128 **Examples**:128 **Examples**:
129 ```bash129 ```bash
130 # Generate all config files130 # Generate all config files
131-dumb-browser init config131+dumber init config
132 132
133 # Only SQLC configuration133 # Only SQLC configuration
134-dumb-browser init config --type=sqlc134+dumber init config --type=sqlc
135 135
136 # Replace existing configs136 # Replace existing configs
137-dumb-browser init config --overwrite137+dumber init config --overwrite
138 ```138 ```
139 139
140 ## Status and Information Commands140 ## Status and Information Commands
141 141
142-### `dumb-browser init status`142+### `dumber init status`
143 **Purpose**: Show initialization status143 **Purpose**: Show initialization status
144 144
145 **Syntax**:145 **Syntax**:
146 ```bash146 ```bash
147-dumb-browser init status [OPTIONS]147+dumber init status [OPTIONS]
148 ```148 ```
149 149
150 **Options**:150 **Options**:
@@ -169,12 +169,12 @@ Structure:
169 Build Status: ✓ Ready (go build succeeds)169 Build Status: ✓ Ready (go build succeeds)
170 ```170 ```
171 171
172-### `dumb-browser init verify`172+### `dumber init verify`
173 **Purpose**: Verify complete installation173 **Purpose**: Verify complete installation
174 174
175 **Syntax**:175 **Syntax**:
176 ```bash176 ```bash
177-dumb-browser init verify [OPTIONS]177+dumber init verify [OPTIONS]
178 ```178 ```
179 179
180 **Options**:180 **Options**:
@@ -192,7 +192,7 @@ dumb-browser init verify [OPTIONS]
192 192
193 **Dependency Installation Failures**:193 **Dependency Installation Failures**:
194 ```bash194 ```bash
195-$ dumb-browser init195+$ dumber init
196 ✗ Installing database dependencies: sqlite3196 ✗ Installing database dependencies: sqlite3
197 Error: CGO_ENABLED=1 required for sqlite3197 Error: CGO_ENABLED=1 required for sqlite3
198 Suggestion: Run 'export CGO_ENABLED=1' and try again198 Suggestion: Run 'export CGO_ENABLED=1' and try again
@@ -201,7 +201,7 @@ $ dumb-browser init
201 201
202 **System Dependency Missing**:202 **System Dependency Missing**:
203 ```bash203 ```bash
204-$ dumb-browser init204+$ dumber init
205 ✗ Installing Wails v3-alpha205 ✗ Installing Wails v3-alpha
206 Error: libwebkit2gtk-4.0-dev not found206 Error: libwebkit2gtk-4.0-dev not found
207 Suggestion: Install with 'sudo apt install libwebkit2gtk-4.0-dev'207 Suggestion: Install with 'sudo apt install libwebkit2gtk-4.0-dev'
@@ -210,9 +210,9 @@ $ dumb-browser init
210 210
211 **Project Already Exists**:211 **Project Already Exists**:
212 ```bash212 ```bash
213-$ dumb-browser init213+$ dumber init
214 ✗ Project already initialized214 ✗ Project already initialized
215- Use --force to reinitialize or run 'dumb-browser init status'215+ Use --force to reinitialize or run 'dumber init status'
216 Exit Code: 4216 Exit Code: 4
217 ```217 ```
218 218
@@ -222,24 +222,24 @@ $ dumb-browser init
222 Commands designed for automated environments:222 Commands designed for automated environments:
223 ```bash223 ```bash
224 # Non-interactive initialization224 # Non-interactive initialization
225-dumb-browser init --verbose > init.log 2>&1225+dumber init --verbose > init.log 2>&1
226 226
227 # Verification in CI227 # Verification in CI
228-dumb-browser init verify --report=ci-report.json228+dumber init verify --report=ci-report.json
229 ```229 ```
230 230
231 ### Development Workflow Integration231 ### Development Workflow Integration
232 ```bash232 ```bash
233 # Quick setup for new contributors233 # Quick setup for new contributors
234 git clone <repo> && cd <repo>234 git clone <repo> && cd <repo>
235-dumb-browser init235+dumber init
236 go test ./...236 go test ./...
237 ```237 ```
238 238
239 ### IDE Integration239 ### IDE Integration
240 Status command provides machine-readable output:240 Status command provides machine-readable output:
241 ```bash241 ```bash
242-dumb-browser init status --format=json | jq '.dependencies.wails.status'242+dumber init status --format=json | jq '.dependencies.wails.status'
243 ```243 ```
244 244
245 This CLI contract supports the initialization phase while providing flexibility for different development scenarios and debugging needs.⏎̸245 This CLI contract supports the initialization phase while providing flexibility for different development scenarios and debugging needs.⏎̸
M specs/001-init-project/contracts/go-module.yaml +5 -5
@@ -1,7 +1,7 @@
1 # Go Module Contract: Project Dependencies1 # Go Module Contract: Project Dependencies
2 2
3 # Expected go.mod file structure after initialization3 # Expected go.mod file structure after initialization
4-module: dumb-browser4+module: dumber
5 go_version: "1.25"5 go_version: "1.25"
6 6
7 # Runtime dependencies (will be installed via go get @latest)7 # Runtime dependencies (will be installed via go get @latest)
@@ -49,7 +49,7 @@ tools:
49 49
50 # Expected go.mod content template50 # Expected go.mod content template
51 go_mod_template: |51 go_mod_template: |
52- module dumb-browser52+ module dumber
53 53
54 go 1.2154 go 1.21
55 55
@@ -102,7 +102,7 @@ build:
102 verification:102 verification:
103 module_valid: "go mod verify"103 module_valid: "go mod verify"
104 dependencies_resolved: "go mod download"104 dependencies_resolved: "go mod download"
105- build_succeeds: "go build ./cmd/dumb-browser"105+ build_succeeds: "go build ./cmd/dumber"
106 tests_pass: "go test ./..."106 tests_pass: "go test ./..."
107 tools_available: 107 tools_available:
108 - "sqlc version"108 - "sqlc version"
@@ -114,13 +114,13 @@ success_criteria:
114 command: "test -f go.mod"114 command: "test -f go.mod"
115 115
116 - name: "module name correct"116 - name: "module name correct"
117- command: "grep -q 'module dumb-browser' go.mod"117+ command: "grep -q 'module dumber' go.mod"
118 118
119 - name: "all dependencies resolved"119 - name: "all dependencies resolved"
120 command: "go mod download && echo 'OK'"120 command: "go mod download && echo 'OK'"
121 121
122 - name: "build succeeds"122 - name: "build succeeds"
123- command: "go build -o /tmp/test-build ./cmd/dumb-browser"123+ command: "go build -o /tmp/test-build ./cmd/dumber"
124 cleanup: "rm -f /tmp/test-build"124 cleanup: "rm -f /tmp/test-build"
125 125
126 - name: "sqlc available"126 - name: "sqlc available"
M specs/001-init-project/data-model.md +5 -5
@@ -1,7 +1,7 @@
1 # Data Model: Project Initialization1 # Data Model: Project Initialization
2 2
3 **Date**: 2025-01-09 3 **Date**: 2025-01-09
4-**Context**: Data structures for dumb-browser project initialization4+**Context**: Data structures for dumber project initialization
5 5
6 ## Core Entities6 ## Core Entities
7 7
@@ -11,7 +11,7 @@
11 **Structure**:11 **Structure**:
12 ```go12 ```go
13 type ModuleConfig struct {13 type ModuleConfig struct {
14- Name string `validate:"required"` // "dumb-browser"14+ Name string `validate:"required"` // "dumber"
15 Version string `validate:"required,semver"` // "v0.1.0"15 Version string `validate:"required,semver"` // "v0.1.0"
16 GoVersion string `validate:"required"` // Go version requirement16 GoVersion string `validate:"required"` // Go version requirement
17 Dependencies []Dependency `validate:"dive"`17 Dependencies []Dependency `validate:"dive"`
@@ -155,9 +155,9 @@ NotStarted → InProgress → Success
155 ## Integration Points155 ## Integration Points
156 156
157 ### With CLI Commands157 ### With CLI Commands
158-- `dumb-browser init` → Creates **ModuleConfig** and **ProjectStructure**158+- `dumber init` → Creates **ModuleConfig** and **ProjectStructure**
159-- `dumb-browser deps install` → Updates **InstallationStatus**159+- `dumber deps install` → Updates **InstallationStatus**
160-- `dumb-browser deps verify` → Validates all **Dependencies**160+- `dumber deps verify` → Validates all **Dependencies**
161 161
162 ### With Configuration Files162 ### With Configuration Files
163 - **go.mod**: Generated from **ModuleConfig**163 - **go.mod**: Generated from **ModuleConfig**
M specs/001-init-project/plan.md +3 -3
@@ -1,7 +1,7 @@
1 # Implementation Plan: Project Initialization with Dependencies1 # Implementation Plan: Project Initialization with Dependencies
2 2
3 **Branch**: `001-init-project` | **Date**: 2025-01-09 | **Spec**: [spec.md](./spec.md)3 **Branch**: `001-init-project` | **Date**: 2025-01-09 | **Spec**: [spec.md](./spec.md)
4-**Input**: Feature specification from `/home/brice/dev/projects/dumb-browser/specs/001-init-project/spec.md`4+**Input**: Feature specification from `/home/brice/dev/projects/dumber/specs/001-init-project/spec.md`
5 5
6 ## Execution Flow (/plan command scope)6 ## Execution Flow (/plan command scope)
7 ```7 ```
@@ -29,7 +29,7 @@
29 - Phase 3-4: Implementation execution (manual or via tools)29 - Phase 3-4: Implementation execution (manual or via tools)
30 30
31 ## Summary31 ## Summary
32-Initialize a Go project with all required dependencies for the dumb-browser application, following the constitution's technical stack: Go + Wails v3-alpha + WebKit2GTK + SQLite with CLI (Cobra + Viper), database (SQLC), and validation (go-playground/validator) components. Use `go get @latest` for all dependencies.32+Initialize a Go project with all required dependencies for the dumber application, following the constitution's technical stack: Go + Wails v3-alpha + WebKit2GTK + SQLite with CLI (Cobra + Viper), database (SQLC), and validation (go-playground/validator) components. Use `go get @latest` for all dependencies.
33 33
34 ## Technical Context34 ## Technical Context
35 **User Details**: We need to add all deps using go get @latest35 **User Details**: We need to add all deps using go get @latest
@@ -47,7 +47,7 @@ Initialize a Go project with all required dependencies for the dumb-browser appl
47 *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*47 *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
48 48
49 **Simplicity**:49 **Simplicity**:
50-- Projects: 1 (single desktop app - dumb-browser)50+- Projects: 1 (single desktop app - dumber)
51 - Using framework directly? YES (Wails, Cobra, Viper directly)51 - Using framework directly? YES (Wails, Cobra, Viper directly)
52 - Single data model? YES (SQLite schema matches Go structs via SQLC)52 - Single data model? YES (SQLite schema matches Go structs via SQLC)
53 - Avoiding patterns? YES (no unnecessary abstractions for initialization)53 - Avoiding patterns? YES (no unnecessary abstractions for initialization)
M specs/001-init-project/quickstart.md +13 -13
@@ -26,15 +26,15 @@ pacman -Q webkit2gtk gtk3
26 Navigate to project root and initialize:26 Navigate to project root and initialize:
27 27
28 ```bash28 ```bash
29-cd /home/brice/dev/projects/dumb-browser29+cd /home/brice/dev/projects/dumber
30 30
31 # Initialize with verbose output to see progress31 # Initialize with verbose output to see progress
32-dumb-browser init --verbose32+dumber init --verbose
33 ```33 ```
34 34
35 **Expected Output**:35 **Expected Output**:
36 ```36 ```
37-✓ Initializing Go module: dumb-browser37+✓ Initializing Go module: dumber
38 ✓ Installing CLI dependencies: cobra, viper38 ✓ Installing CLI dependencies: cobra, viper
39 ✓ Installing database dependencies: ncruces/go-sqlite3, sqlc39 ✓ Installing database dependencies: ncruces/go-sqlite3, sqlc
40 ✓ Installing validation: go-playground/validator40 ✓ Installing validation: go-playground/validator
@@ -48,8 +48,8 @@ Project initialized successfully!
48 48
49 Next steps:49 Next steps:
50 1. Review generated configs/50 1. Review generated configs/
51- 2. Run: go build ./cmd/dumb-browser51+ 2. Run: go build ./cmd/dumber
52- 3. Start development: dumb-browser --help52+ 3. Start development: dumber --help
53 ```53 ```
54 54
55 ## Step 2: Verify Installation55 ## Step 2: Verify Installation
@@ -58,7 +58,7 @@ Check that all components are correctly installed:
58 58
59 ```bash59 ```bash
60 # Verify project status60 # Verify project status
61-dumb-browser init status61+dumber init status
62 62
63 # Expected output:63 # Expected output:
64 # Project Status: ✓ Initialized64 # Project Status: ✓ Initialized
@@ -72,7 +72,7 @@ Detailed verification:
72 ```bash72 ```bash
73 # Check go.mod exists and is valid73 # Check go.mod exists and is valid
74 test -f go.mod && echo "✓ go.mod exists"74 test -f go.mod && echo "✓ go.mod exists"
75-grep -q "module dumb-browser" go.mod && echo "✓ Module name correct"75+grep -q "module dumber" go.mod && echo "✓ Module name correct"
76 76
77 # Verify dependencies are downloadable77 # Verify dependencies are downloadable
78 go mod download && echo "✓ Dependencies resolved"78 go mod download && echo "✓ Dependencies resolved"
@@ -110,10 +110,10 @@ Test the full application with Wails:
110 export CGO_ENABLED=1110 export CGO_ENABLED=1
111 111
112 # Test full application build112 # Test full application build
113-go build ./cmd/dumb-browser && echo "✓ Full build successful"113+go build ./cmd/dumber && echo "✓ Full build successful"
114 114
115 # Clean up test binary115 # Clean up test binary
116-rm -f dumb-browser116+rm -f dumber
117 ```117 ```
118 118
119 ## Step 5: Test Project Structure119 ## Step 5: Test Project Structure
@@ -145,7 +145,7 @@ test -f configs/sqlc.yaml && echo "✓ SQLC config exists"
145 sqlc compile -f configs/sqlc.yaml && echo "✓ SQLC config valid"145 sqlc compile -f configs/sqlc.yaml && echo "✓ SQLC config valid"
146 146
147 # Check basic project files exist (if generated)147 # Check basic project files exist (if generated)
148-test -f cmd/dumb-browser/main.go && echo "✓ Main entry point exists"148+test -f cmd/dumber/main.go && echo "✓ Main entry point exists"
149 ```149 ```
150 150
151 ## Step 7: Test Module Dependencies151 ## Step 7: Test Module Dependencies
@@ -186,7 +186,7 @@ go test -race ./... && echo "✓ No race conditions detected"
186 **Solution** (Arch Linux):186 **Solution** (Arch Linux):
187 ```bash187 ```bash
188 sudo pacman -S webkit2gtk gtk3188 sudo pacman -S webkit2gtk gtk3
189-dumb-browser init deps --only=wails189+dumber init deps --only=wails
190 ```190 ```
191 191
192 ### Issue: CGO disabled for Wails192 ### Issue: CGO disabled for Wails
@@ -194,7 +194,7 @@ dumb-browser init deps --only=wails
194 **Solution**:194 **Solution**:
195 ```bash195 ```bash
196 export CGO_ENABLED=1196 export CGO_ENABLED=1
197-go build ./cmd/dumb-browser197+go build ./cmd/dumber
198 ```198 ```
199 199
200 ### Issue: Wrong SQLite driver200 ### Issue: Wrong SQLite driver
@@ -211,7 +211,7 @@ grep sqlite3 go.mod
211 ```bash211 ```bash
212 go env -w GOPROXY=proxy.golang.org,direct212 go env -w GOPROXY=proxy.golang.org,direct
213 go env -w GOSUMDB=sum.golang.org213 go env -w GOSUMDB=sum.golang.org
214-dumb-browser init deps214+dumber init deps
215 ```215 ```
216 216
217 ## Success Criteria217 ## Success Criteria
M specs/001-init-project/research.md +5 -5
@@ -1,20 +1,20 @@
1 # Research: Project Initialization Dependencies1 # Research: Project Initialization Dependencies
2 2
3 **Date**: 2025-01-09 3 **Date**: 2025-01-09
4-**Context**: Resolve technical unknowns for dumb-browser project initialization4+**Context**: Resolve technical unknowns for dumber project initialization
5 5
6 ## Research Questions Resolved6 ## Research Questions Resolved
7 7
8 ### 1. Go Module Initialization8 ### 1. Go Module Initialization
9-**Decision**: Use `go mod init dumb-browser`9+**Decision**: Use `go mod init dumber`
10 **Rationale**: 10 **Rationale**:
11 - Simple module name matching the binary name11 - Simple module name matching the binary name
12 - Local development focus (no public import path needed)12 - Local development focus (no public import path needed)
13 - Follows Go module naming conventions13 - Follows Go module naming conventions
14 14
15 **Alternatives considered**: 15 **Alternatives considered**:
16-- `github.com/user/dumb-browser` - rejected (unnecessary complexity for local tool)16+- `github.com/user/dumber` - rejected (unnecessary complexity for local tool)
17-- `dumb-browser/v1` - rejected (premature versioning)17+- `dumber/v1` - rejected (premature versioning)
18 18
19 ### 2. Wails v3-Alpha Compatibility19 ### 2. Wails v3-Alpha Compatibility
20 **Decision**: Use Wails v3.0.0-alpha.28 (latest available)20 **Decision**: Use Wails v3.0.0-alpha.28 (latest available)
@@ -117,7 +117,7 @@ Based on research, the installation sequence should be:
117 117
118 1. **Initialize Go module**118 1. **Initialize Go module**
119 ```bash119 ```bash
120- go mod init dumb-browser120+ go mod init dumber
121 ```121 ```
122 122
123 2. **Install CLI dependencies**123 2. **Install CLI dependencies**
M specs/001-init-project/spec.md +1 -1
@@ -39,7 +39,7 @@
39 ## User Scenarios & Testing *(mandatory)*39 ## User Scenarios & Testing *(mandatory)*
40 40
41 ### Primary User Story41 ### Primary User Story
42-As a developer starting work on the dumb-browser project, I need a properly initialized Go project with all required dependencies installed so that I can begin implementing features according to the project constitution without manual setup overhead.42+As a developer starting work on the dumber project, I need a properly initialized Go project with all required dependencies installed so that I can begin implementing features according to the project constitution without manual setup overhead.
43 43
44 ### Acceptance Scenarios44 ### Acceptance Scenarios
45 1. **Given** an empty project directory, **When** project initialization is complete, **Then** the project has a valid Go module structure45 1. **Given** an empty project directory, **When** project initialization is complete, **Then** the project has a valid Go module structure
M specs/001-init-project/tasks.md +4 -4
@@ -1,6 +1,6 @@
1 # Tasks: Project Initialization with Dependencies1 # Tasks: Project Initialization with Dependencies
2 2
3-**Input**: Design documents from `/home/brice/dev/projects/dumb-browser/specs/001-init-project/`3+**Input**: Design documents from `/home/brice/dev/projects/dumber/specs/001-init-project/`
4 **Prerequisites**: plan.md, research.md, data-model.md, contracts/4 **Prerequisites**: plan.md, research.md, data-model.md, contracts/
5 5
6 ## Execution Flow (main)6 ## Execution Flow (main)
@@ -15,8 +15,8 @@ Single project structure: `cmd/`, `internal/`, `tests/` at repository root per c
15 15
16 ## Phase 3.1: Setup and Structure16 ## Phase 3.1: Setup and Structure
17 17
18-- [ ] T001 Initialize Go module with `go mod init dumb-browser` in project root18+- [ ] T001 Initialize Go module with `go mod init dumber` in project root
19-- [ ] T002 Create project directory structure: `cmd/dumb-browser/`, `internal/{cli,db,parser,browser}/`, `migrations/`, `configs/`, `tests/{contract,integration,unit}/`19+- [ ] T002 Create project directory structure: `cmd/dumber/`, `internal/{cli,db,parser,browser}/`, `migrations/`, `configs/`, `tests/{contract,integration,unit}/`
20 - [ ] T003 [P] Configure Git repository with .gitignore for Go projects20 - [ ] T003 [P] Configure Git repository with .gitignore for Go projects
21 21
22 ## Phase 3.2: Dependency Installation22 ## Phase 3.2: Dependency Installation
@@ -44,7 +44,7 @@ Single project structure: `cmd/`, `internal/`, `tests/` at repository root per c
44 - [ ] T016 [P] ModuleConfig model in `internal/models/module.go`44 - [ ] T016 [P] ModuleConfig model in `internal/models/module.go`
45 - [ ] T017 [P] ProjectStructure model in `internal/models/structure.go`45 - [ ] T017 [P] ProjectStructure model in `internal/models/structure.go`
46 - [ ] T018 [P] InstallationStatus model in `internal/models/status.go`46 - [ ] T018 [P] InstallationStatus model in `internal/models/status.go`
47-- [ ] T019 CLI root command setup in `cmd/dumb-browser/main.go`47+- [ ] T019 CLI root command setup in `cmd/dumber/main.go`
48 - [ ] T020 CLI init command implementation in `internal/cli/init.go`48 - [ ] T020 CLI init command implementation in `internal/cli/init.go`
49 - [ ] T021 Dependency installer service in `internal/services/installer.go`49 - [ ] T021 Dependency installer service in `internal/services/installer.go`
50 - [ ] T022 Project structure creator in `internal/services/structure.go`50 - [ ] T022 Project structure creator in `internal/services/structure.go`

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