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
- f17cf684891fb043c1eee779cdc245a4f0caee4d
- author
- bamen · · 2025-09-08 16:45 UTC
- committer
- bamen · · 2025-09-08 16:34 UTC
- parent
- bcef92d
- signature
- unsigned
31 files · +5256 -110
M .gitignore +2 -2
| @@ -7,8 +7,8 @@ dist/ | |||
| 7 | 7 | *.dylib | |
| 8 | 8 | ||
| 9 | 9 | # Binaries for programs and plugins | |
| 10 | - | dumb-browser | |
| 11 | - | dumb-browser.exe | |
| 10 | + | dumber | |
| 11 | + | dumber.exe | |
| 12 | 12 | ||
| 13 | 13 | # Test binary, built with `go test -c` | |
| 14 | 14 | *.test | |
M Makefile +3 -3
| @@ -1,10 +1,10 @@ | |||
| 1 | - | # Makefile for dumb-browser | |
| 1 | + | # Makefile for dumber | |
| 2 | 2 | ||
| 3 | 3 | .PHONY: build test lint clean install-tools dev generate help | |
| 4 | 4 | ||
| 5 | 5 | # Variables | |
| 6 | - | BINARY_NAME=dumb-browser | |
| 7 | - | MAIN_PATH=./cmd/dumb-browser | |
| 6 | + | BINARY_NAME=dumber | |
| 7 | + | MAIN_PATH=./cmd/dumber | |
| 8 | 8 | DIST_DIR=dist | |
| 9 | 9 | ||
| 10 | 10 | # 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-browser | |
| 1 | + | module github.com/bnema/dumber | |
| 2 | 2 | ||
| 3 | 3 | go 1.25.1 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/fsnotify/fsnotify v1.9.0 // indirect | |
| 7 | - | github.com/gabriel-vasile/mimetype v1.4.8 // indirect | |
| 8 | - | github.com/go-playground/locales v0.14.1 // indirect | |
| 9 | - | github.com/go-playground/universal-translator v0.18.1 // indirect | |
| 10 | - | github.com/go-playground/validator/v10 v10.27.0 // indirect | |
| 6 | + | github.com/fsnotify/fsnotify v1.9.0 | |
| 7 | + | github.com/ncruces/go-sqlite3 v0.29.0 | |
| 8 | + | github.com/spf13/cobra v1.10.1 | |
| 9 | + | github.com/spf13/viper v1.21.0 | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | require ( | |
| 11 | 13 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| 12 | 14 | 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 | 15 | github.com/ncruces/julianday v1.0.0 // indirect | |
| 16 | 16 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | |
| 17 | 17 | github.com/sagikazarmark/locafero v0.11.0 // indirect | |
| 18 | 18 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | |
| 19 | 19 | github.com/spf13/afero v1.15.0 // indirect | |
| 20 | 20 | github.com/spf13/cast v1.10.0 // indirect | |
| 21 | - | github.com/spf13/cobra v1.10.1 // indirect | |
| 22 | 21 | 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 | 22 | github.com/subosito/gotenv v1.6.0 // indirect | |
| 26 | 23 | github.com/tetratelabs/wazero v1.9.0 // indirect | |
| 27 | 24 | 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 | 25 | golang.org/x/sys v0.36.0 // indirect | |
| 31 | 26 | 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 | 12 | - Support any dmenu-compatible launcher (fuzzel, rofi, tofi, wofi, bemenu) | |
| 13 | 13 | - Standard stdin/stdout protocol - no launcher-specific integrations | |
| 14 | 14 | - 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 | 17 | ### III. Smart URL Handling | |
| 18 | 18 | - Direct URL detection: `reddit.com` → navigate directly | |
M migrations/001_initial.sql +1 -1
| @@ -1,4 +1,4 @@ | |||
| 1 | - | -- Initial database schema for dumb-browser | |
| 1 | + | -- Initial database schema for dumber | |
| 2 | 2 | -- History tracking for visited URLs | |
| 3 | 3 | ||
| 4 | 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 | 6 | ## Root Command | |
| 7 | 7 | ||
| 8 | - | ### `dumb-browser init` | |
| 8 | + | ### `dumber init` | |
| 9 | 9 | **Purpose**: Initialize the project with all dependencies | |
| 10 | 10 | ||
| 11 | 11 | **Syntax**: | |
| 12 | 12 | ```bash | |
| 13 | - | dumb-browser init [OPTIONS] | |
| 13 | + | dumber init [OPTIONS] | |
| 14 | 14 | ``` | |
| 15 | 15 | ||
| 16 | 16 | **Options**: | |
| @@ -22,16 +22,16 @@ dumb-browser init [OPTIONS] | |||
| 22 | 22 | **Examples**: | |
| 23 | 23 | ```bash | |
| 24 | 24 | # Basic initialization | |
| 25 | - | dumb-browser init | |
| 25 | + | dumber init | |
| 26 | 26 | ||
| 27 | 27 | # Preview mode | |
| 28 | - | dumb-browser init --dry-run | |
| 28 | + | dumber init --dry-run | |
| 29 | 29 | ||
| 30 | 30 | # Force reinitialize existing project | |
| 31 | - | dumb-browser init --force --verbose | |
| 31 | + | dumber init --force --verbose | |
| 32 | 32 | ||
| 33 | 33 | # Specify Go version | |
| 34 | - | dumb-browser init --go-version=1.21 | |
| 34 | + | dumber init --go-version=1.21 | |
| 35 | 35 | ``` | |
| 36 | 36 | ||
| 37 | 37 | **Exit Codes**: | |
| @@ -43,7 +43,7 @@ dumb-browser init --go-version=1.21 | |||
| 43 | 43 | ||
| 44 | 44 | **Output Format**: | |
| 45 | 45 | ``` | |
| 46 | - | ✓ Initializing Go module: dumb-browser | |
| 46 | + | ✓ Initializing Go module: dumber | |
| 47 | 47 | ✓ Installing CLI dependencies: cobra, viper | |
| 48 | 48 | ✓ Installing database dependencies: sqlite3, sqlc | |
| 49 | 49 | ✓ Installing validation: go-playground/validator | |
| @@ -57,18 +57,18 @@ Project initialized successfully! | |||
| 57 | 57 | ||
| 58 | 58 | Next steps: | |
| 59 | 59 | 1. Review generated configs/ | |
| 60 | - | 2. Run: go build ./cmd/dumb-browser | |
| 61 | - | 3. Start development: dumb-browser --help | |
| 60 | + | 2. Run: go build ./cmd/dumber | |
| 61 | + | 3. Start development: dumber --help | |
| 62 | 62 | ``` | |
| 63 | 63 | ||
| 64 | 64 | ## Subcommands | |
| 65 | 65 | ||
| 66 | - | ### `dumb-browser init deps` | |
| 66 | + | ### `dumber init deps` | |
| 67 | 67 | **Purpose**: Install only dependencies without project structure | |
| 68 | 68 | ||
| 69 | 69 | **Syntax**: | |
| 70 | 70 | ```bash | |
| 71 | - | dumb-browser init deps [OPTIONS] | |
| 71 | + | dumber init deps [OPTIONS] | |
| 72 | 72 | ``` | |
| 73 | 73 | ||
| 74 | 74 | **Options**: | |
| @@ -80,21 +80,21 @@ dumb-browser init deps [OPTIONS] | |||
| 80 | 80 | **Examples**: | |
| 81 | 81 | ```bash | |
| 82 | 82 | # Install only CLI dependencies | |
| 83 | - | dumb-browser init deps --only=cli | |
| 83 | + | dumber init deps --only=cli | |
| 84 | 84 | ||
| 85 | 85 | # Install everything except Wails | |
| 86 | - | dumb-browser init deps --skip=wails | |
| 86 | + | dumber init deps --skip=wails | |
| 87 | 87 | ||
| 88 | 88 | # Check current dependency status | |
| 89 | - | dumb-browser init deps --check | |
| 89 | + | dumber init deps --check | |
| 90 | 90 | ``` | |
| 91 | 91 | ||
| 92 | - | ### `dumb-browser init structure` | |
| 92 | + | ### `dumber init structure` | |
| 93 | 93 | **Purpose**: Create project directory structure only | |
| 94 | 94 | ||
| 95 | 95 | **Syntax**: | |
| 96 | 96 | ```bash | |
| 97 | - | dumb-browser init structure [OPTIONS] | |
| 97 | + | dumber init structure [OPTIONS] | |
| 98 | 98 | ``` | |
| 99 | 99 | ||
| 100 | 100 | **Options**: | |
| @@ -104,21 +104,21 @@ dumb-browser init structure [OPTIONS] | |||
| 104 | 104 | **Examples**: | |
| 105 | 105 | ```bash | |
| 106 | 106 | # Create full project structure | |
| 107 | - | dumb-browser init structure | |
| 107 | + | dumber init structure | |
| 108 | 108 | ||
| 109 | 109 | # Preview directory layout | |
| 110 | - | dumb-browser init structure --preview | |
| 110 | + | dumber init structure --preview | |
| 111 | 111 | ||
| 112 | 112 | # Minimal structure (no example files) | |
| 113 | - | dumb-browser init structure --template=minimal | |
| 113 | + | dumber init structure --template=minimal | |
| 114 | 114 | ``` | |
| 115 | 115 | ||
| 116 | - | ### `dumb-browser init config` | |
| 116 | + | ### `dumber init config` | |
| 117 | 117 | **Purpose**: Generate configuration files only | |
| 118 | 118 | ||
| 119 | 119 | **Syntax**: | |
| 120 | 120 | ```bash | |
| 121 | - | dumb-browser init config [OPTIONS] | |
| 121 | + | dumber init config [OPTIONS] | |
| 122 | 122 | ``` | |
| 123 | 123 | ||
| 124 | 124 | **Options**: | |
| @@ -128,23 +128,23 @@ dumb-browser init config [OPTIONS] | |||
| 128 | 128 | **Examples**: | |
| 129 | 129 | ```bash | |
| 130 | 130 | # Generate all config files | |
| 131 | - | dumb-browser init config | |
| 131 | + | dumber init config | |
| 132 | 132 | ||
| 133 | 133 | # Only SQLC configuration | |
| 134 | - | dumb-browser init config --type=sqlc | |
| 134 | + | dumber init config --type=sqlc | |
| 135 | 135 | ||
| 136 | 136 | # Replace existing configs | |
| 137 | - | dumb-browser init config --overwrite | |
| 137 | + | dumber init config --overwrite | |
| 138 | 138 | ``` | |
| 139 | 139 | ||
| 140 | 140 | ## Status and Information Commands | |
| 141 | 141 | ||
| 142 | - | ### `dumb-browser init status` | |
| 142 | + | ### `dumber init status` | |
| 143 | 143 | **Purpose**: Show initialization status | |
| 144 | 144 | ||
| 145 | 145 | **Syntax**: | |
| 146 | 146 | ```bash | |
| 147 | - | dumb-browser init status [OPTIONS] | |
| 147 | + | dumber init status [OPTIONS] | |
| 148 | 148 | ``` | |
| 149 | 149 | ||
| 150 | 150 | **Options**: | |
| @@ -169,12 +169,12 @@ Structure: | |||
| 169 | 169 | Build Status: ✓ Ready (go build succeeds) | |
| 170 | 170 | ``` | |
| 171 | 171 | ||
| 172 | - | ### `dumb-browser init verify` | |
| 172 | + | ### `dumber init verify` | |
| 173 | 173 | **Purpose**: Verify complete installation | |
| 174 | 174 | ||
| 175 | 175 | **Syntax**: | |
| 176 | 176 | ```bash | |
| 177 | - | dumb-browser init verify [OPTIONS] | |
| 177 | + | dumber init verify [OPTIONS] | |
| 178 | 178 | ``` | |
| 179 | 179 | ||
| 180 | 180 | **Options**: | |
| @@ -192,7 +192,7 @@ dumb-browser init verify [OPTIONS] | |||
| 192 | 192 | ||
| 193 | 193 | **Dependency Installation Failures**: | |
| 194 | 194 | ```bash | |
| 195 | - | $ dumb-browser init | |
| 195 | + | $ dumber init | |
| 196 | 196 | ✗ Installing database dependencies: sqlite3 | |
| 197 | 197 | Error: CGO_ENABLED=1 required for sqlite3 | |
| 198 | 198 | Suggestion: Run 'export CGO_ENABLED=1' and try again | |
| @@ -201,7 +201,7 @@ $ dumb-browser init | |||
| 201 | 201 | ||
| 202 | 202 | **System Dependency Missing**: | |
| 203 | 203 | ```bash | |
| 204 | - | $ dumb-browser init | |
| 204 | + | $ dumber init | |
| 205 | 205 | ✗ Installing Wails v3-alpha | |
| 206 | 206 | Error: libwebkit2gtk-4.0-dev not found | |
| 207 | 207 | Suggestion: Install with 'sudo apt install libwebkit2gtk-4.0-dev' | |
| @@ -210,9 +210,9 @@ $ dumb-browser init | |||
| 210 | 210 | ||
| 211 | 211 | **Project Already Exists**: | |
| 212 | 212 | ```bash | |
| 213 | - | $ dumb-browser init | |
| 213 | + | $ dumber init | |
| 214 | 214 | ✗ 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 | 216 | Exit Code: 4 | |
| 217 | 217 | ``` | |
| 218 | 218 | ||
| @@ -222,24 +222,24 @@ $ dumb-browser init | |||
| 222 | 222 | Commands designed for automated environments: | |
| 223 | 223 | ```bash | |
| 224 | 224 | # Non-interactive initialization | |
| 225 | - | dumb-browser init --verbose > init.log 2>&1 | |
| 225 | + | dumber init --verbose > init.log 2>&1 | |
| 226 | 226 | ||
| 227 | 227 | # Verification in CI | |
| 228 | - | dumb-browser init verify --report=ci-report.json | |
| 228 | + | dumber init verify --report=ci-report.json | |
| 229 | 229 | ``` | |
| 230 | 230 | ||
| 231 | 231 | ### Development Workflow Integration | |
| 232 | 232 | ```bash | |
| 233 | 233 | # Quick setup for new contributors | |
| 234 | 234 | git clone <repo> && cd <repo> | |
| 235 | - | dumb-browser init | |
| 235 | + | dumber init | |
| 236 | 236 | go test ./... | |
| 237 | 237 | ``` | |
| 238 | 238 | ||
| 239 | 239 | ### IDE Integration | |
| 240 | 240 | Status command provides machine-readable output: | |
| 241 | 241 | ```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 | 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 | 1 | # Go Module Contract: Project Dependencies | |
| 2 | 2 | ||
| 3 | 3 | # Expected go.mod file structure after initialization | |
| 4 | - | module: dumb-browser | |
| 4 | + | module: dumber | |
| 5 | 5 | go_version: "1.25" | |
| 6 | 6 | ||
| 7 | 7 | # Runtime dependencies (will be installed via go get @latest) | |
| @@ -49,7 +49,7 @@ tools: | |||
| 49 | 49 | ||
| 50 | 50 | # Expected go.mod content template | |
| 51 | 51 | go_mod_template: | | |
| 52 | - | module dumb-browser | |
| 52 | + | module dumber | |
| 53 | 53 | ||
| 54 | 54 | go 1.21 | |
| 55 | 55 | ||
| @@ -102,7 +102,7 @@ build: | |||
| 102 | 102 | verification: | |
| 103 | 103 | module_valid: "go mod verify" | |
| 104 | 104 | dependencies_resolved: "go mod download" | |
| 105 | - | build_succeeds: "go build ./cmd/dumb-browser" | |
| 105 | + | build_succeeds: "go build ./cmd/dumber" | |
| 106 | 106 | tests_pass: "go test ./..." | |
| 107 | 107 | tools_available: | |
| 108 | 108 | - "sqlc version" | |
| @@ -114,13 +114,13 @@ success_criteria: | |||
| 114 | 114 | command: "test -f go.mod" | |
| 115 | 115 | ||
| 116 | 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 | 119 | - name: "all dependencies resolved" | |
| 120 | 120 | command: "go mod download && echo 'OK'" | |
| 121 | 121 | ||
| 122 | 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 | 124 | cleanup: "rm -f /tmp/test-build" | |
| 125 | 125 | ||
| 126 | 126 | - name: "sqlc available" | |
M specs/001-init-project/data-model.md +5 -5
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | # Data Model: Project Initialization | |
| 2 | 2 | ||
| 3 | 3 | **Date**: 2025-01-09 | |
| 4 | - | **Context**: Data structures for dumb-browser project initialization | |
| 4 | + | **Context**: Data structures for dumber project initialization | |
| 5 | 5 | ||
| 6 | 6 | ## Core Entities | |
| 7 | 7 | ||
| @@ -11,7 +11,7 @@ | |||
| 11 | 11 | **Structure**: | |
| 12 | 12 | ```go | |
| 13 | 13 | type ModuleConfig struct { | |
| 14 | - | Name string `validate:"required"` // "dumb-browser" | |
| 14 | + | Name string `validate:"required"` // "dumber" | |
| 15 | 15 | Version string `validate:"required,semver"` // "v0.1.0" | |
| 16 | 16 | GoVersion string `validate:"required"` // Go version requirement | |
| 17 | 17 | Dependencies []Dependency `validate:"dive"` | |
| @@ -155,9 +155,9 @@ NotStarted → InProgress → Success | |||
| 155 | 155 | ## Integration Points | |
| 156 | 156 | ||
| 157 | 157 | ### With CLI Commands | |
| 158 | - | - `dumb-browser init` → Creates **ModuleConfig** and **ProjectStructure** | |
| 159 | - | - `dumb-browser deps install` → Updates **InstallationStatus** | |
| 160 | - | - `dumb-browser deps verify` → Validates all **Dependencies** | |
| 158 | + | - `dumber init` → Creates **ModuleConfig** and **ProjectStructure** | |
| 159 | + | - `dumber deps install` → Updates **InstallationStatus** | |
| 160 | + | - `dumber deps verify` → Validates all **Dependencies** | |
| 161 | 161 | ||
| 162 | 162 | ### With Configuration Files | |
| 163 | 163 | - **go.mod**: Generated from **ModuleConfig** | |
M specs/001-init-project/plan.md +3 -3
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | # Implementation Plan: Project Initialization with Dependencies | |
| 2 | 2 | ||
| 3 | 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 | 6 | ## Execution Flow (/plan command scope) | |
| 7 | 7 | ``` | |
| @@ -29,7 +29,7 @@ | |||
| 29 | 29 | - Phase 3-4: Implementation execution (manual or via tools) | |
| 30 | 30 | ||
| 31 | 31 | ## 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 | 34 | ## Technical Context | |
| 35 | 35 | **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 | 47 | *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* | |
| 48 | 48 | ||
| 49 | 49 | **Simplicity**: | |
| 50 | - | - Projects: 1 (single desktop app - dumb-browser) | |
| 50 | + | - Projects: 1 (single desktop app - dumber) | |
| 51 | 51 | - Using framework directly? YES (Wails, Cobra, Viper directly) | |
| 52 | 52 | - Single data model? YES (SQLite schema matches Go structs via SQLC) | |
| 53 | 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 | 26 | Navigate to project root and initialize: | |
| 27 | 27 | ||
| 28 | 28 | ```bash | |
| 29 | - | cd /home/brice/dev/projects/dumb-browser | |
| 29 | + | cd /home/brice/dev/projects/dumber | |
| 30 | 30 | ||
| 31 | 31 | # Initialize with verbose output to see progress | |
| 32 | - | dumb-browser init --verbose | |
| 32 | + | dumber init --verbose | |
| 33 | 33 | ``` | |
| 34 | 34 | ||
| 35 | 35 | **Expected Output**: | |
| 36 | 36 | ``` | |
| 37 | - | ✓ Initializing Go module: dumb-browser | |
| 37 | + | ✓ Initializing Go module: dumber | |
| 38 | 38 | ✓ Installing CLI dependencies: cobra, viper | |
| 39 | 39 | ✓ Installing database dependencies: ncruces/go-sqlite3, sqlc | |
| 40 | 40 | ✓ Installing validation: go-playground/validator | |
| @@ -48,8 +48,8 @@ Project initialized successfully! | |||
| 48 | 48 | ||
| 49 | 49 | Next steps: | |
| 50 | 50 | 1. Review generated configs/ | |
| 51 | - | 2. Run: go build ./cmd/dumb-browser | |
| 52 | - | 3. Start development: dumb-browser --help | |
| 51 | + | 2. Run: go build ./cmd/dumber | |
| 52 | + | 3. Start development: dumber --help | |
| 53 | 53 | ``` | |
| 54 | 54 | ||
| 55 | 55 | ## Step 2: Verify Installation | |
| @@ -58,7 +58,7 @@ Check that all components are correctly installed: | |||
| 58 | 58 | ||
| 59 | 59 | ```bash | |
| 60 | 60 | # Verify project status | |
| 61 | - | dumb-browser init status | |
| 61 | + | dumber init status | |
| 62 | 62 | ||
| 63 | 63 | # Expected output: | |
| 64 | 64 | # Project Status: ✓ Initialized | |
| @@ -72,7 +72,7 @@ Detailed verification: | |||
| 72 | 72 | ```bash | |
| 73 | 73 | # Check go.mod exists and is valid | |
| 74 | 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 | 77 | # Verify dependencies are downloadable | |
| 78 | 78 | go mod download && echo "✓ Dependencies resolved" | |
| @@ -110,10 +110,10 @@ Test the full application with Wails: | |||
| 110 | 110 | export CGO_ENABLED=1 | |
| 111 | 111 | ||
| 112 | 112 | # 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 | 115 | # Clean up test binary | |
| 116 | - | rm -f dumb-browser | |
| 116 | + | rm -f dumber | |
| 117 | 117 | ``` | |
| 118 | 118 | ||
| 119 | 119 | ## Step 5: Test Project Structure | |
| @@ -145,7 +145,7 @@ test -f configs/sqlc.yaml && echo "✓ SQLC config exists" | |||
| 145 | 145 | sqlc compile -f configs/sqlc.yaml && echo "✓ SQLC config valid" | |
| 146 | 146 | ||
| 147 | 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 | 151 | ## Step 7: Test Module Dependencies | |
| @@ -186,7 +186,7 @@ go test -race ./... && echo "✓ No race conditions detected" | |||
| 186 | 186 | **Solution** (Arch Linux): | |
| 187 | 187 | ```bash | |
| 188 | 188 | sudo pacman -S webkit2gtk gtk3 | |
| 189 | - | dumb-browser init deps --only=wails | |
| 189 | + | dumber init deps --only=wails | |
| 190 | 190 | ``` | |
| 191 | 191 | ||
| 192 | 192 | ### Issue: CGO disabled for Wails | |
| @@ -194,7 +194,7 @@ dumb-browser init deps --only=wails | |||
| 194 | 194 | **Solution**: | |
| 195 | 195 | ```bash | |
| 196 | 196 | export CGO_ENABLED=1 | |
| 197 | - | go build ./cmd/dumb-browser | |
| 197 | + | go build ./cmd/dumber | |
| 198 | 198 | ``` | |
| 199 | 199 | ||
| 200 | 200 | ### Issue: Wrong SQLite driver | |
| @@ -211,7 +211,7 @@ grep sqlite3 go.mod | |||
| 211 | 211 | ```bash | |
| 212 | 212 | go env -w GOPROXY=proxy.golang.org,direct | |
| 213 | 213 | go env -w GOSUMDB=sum.golang.org | |
| 214 | - | dumb-browser init deps | |
| 214 | + | dumber init deps | |
| 215 | 215 | ``` | |
| 216 | 216 | ||
| 217 | 217 | ## Success Criteria | |
M specs/001-init-project/research.md +5 -5
| @@ -1,20 +1,20 @@ | |||
| 1 | 1 | # Research: Project Initialization Dependencies | |
| 2 | 2 | ||
| 3 | 3 | **Date**: 2025-01-09 | |
| 4 | - | **Context**: Resolve technical unknowns for dumb-browser project initialization | |
| 4 | + | **Context**: Resolve technical unknowns for dumber project initialization | |
| 5 | 5 | ||
| 6 | 6 | ## Research Questions Resolved | |
| 7 | 7 | ||
| 8 | 8 | ### 1. Go Module Initialization | |
| 9 | - | **Decision**: Use `go mod init dumb-browser` | |
| 9 | + | **Decision**: Use `go mod init dumber` | |
| 10 | 10 | **Rationale**: | |
| 11 | 11 | - Simple module name matching the binary name | |
| 12 | 12 | - Local development focus (no public import path needed) | |
| 13 | 13 | - Follows Go module naming conventions | |
| 14 | 14 | ||
| 15 | 15 | **Alternatives considered**: | |
| 16 | - | - `github.com/user/dumb-browser` - rejected (unnecessary complexity for local tool) | |
| 17 | - | - `dumb-browser/v1` - rejected (premature versioning) | |
| 16 | + | - `github.com/user/dumber` - rejected (unnecessary complexity for local tool) | |
| 17 | + | - `dumber/v1` - rejected (premature versioning) | |
| 18 | 18 | ||
| 19 | 19 | ### 2. Wails v3-Alpha Compatibility | |
| 20 | 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 | 118 | 1. **Initialize Go module** | |
| 119 | 119 | ```bash | |
| 120 | - | go mod init dumb-browser | |
| 120 | + | go mod init dumber | |
| 121 | 121 | ``` | |
| 122 | 122 | ||
| 123 | 123 | 2. **Install CLI dependencies** | |
M specs/001-init-project/spec.md +1 -1
| @@ -39,7 +39,7 @@ | |||
| 39 | 39 | ## User Scenarios & Testing *(mandatory)* | |
| 40 | 40 | ||
| 41 | 41 | ### 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 | 44 | ### Acceptance Scenarios | |
| 45 | 45 | 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 | 1 | # 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 | 4 | **Prerequisites**: plan.md, research.md, data-model.md, contracts/ | |
| 5 | 5 | ||
| 6 | 6 | ## Execution Flow (main) | |
| @@ -15,8 +15,8 @@ Single project structure: `cmd/`, `internal/`, `tests/` at repository root per c | |||
| 15 | 15 | ||
| 16 | 16 | ## Phase 3.1: Setup and Structure | |
| 17 | 17 | ||
| 18 | - | - [ ] T001 Initialize Go module with `go mod init dumb-browser` in project root | |
| 19 | - | - [ ] T002 Create project directory structure: `cmd/dumb-browser/`, `internal/{cli,db,parser,browser}/`, `migrations/`, `configs/`, `tests/{contract,integration,unit}/` | |
| 18 | + | - [ ] T001 Initialize Go module with `go mod init dumber` in project root | |
| 19 | + | - [ ] T002 Create project directory structure: `cmd/dumber/`, `internal/{cli,db,parser,browser}/`, `migrations/`, `configs/`, `tests/{contract,integration,unit}/` | |
| 20 | 20 | - [ ] T003 [P] Configure Git repository with .gitignore for Go projects | |
| 21 | 21 | ||
| 22 | 22 | ## Phase 3.2: Dependency Installation | |
| @@ -44,7 +44,7 @@ Single project structure: `cmd/`, `internal/`, `tests/` at repository root per c | |||
| 44 | 44 | - [ ] T016 [P] ModuleConfig model in `internal/models/module.go` | |
| 45 | 45 | - [ ] T017 [P] ProjectStructure model in `internal/models/structure.go` | |
| 46 | 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 | 48 | - [ ] T020 CLI init command implementation in `internal/cli/init.go` | |
| 49 | 49 | - [ ] T021 Dependency installer service in `internal/services/installer.go` | |
| 50 | 50 | - [ ] T022 Project structure creator in `internal/services/structure.go` | |